From 1e88d55b432fa25c449602ac7e114c8782dbecf5 Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle
Date: Mon, 20 Jan 2020 15:58:16 +0100
Subject: [PATCH 001/940] add empty files
---
shopfloor/__init__.py | 0
shopfloor/__manifest__.py | 0
shopfloor/models/__init__.py | 0
3 files changed, 0 insertions(+), 0 deletions(-)
create mode 100644 shopfloor/__init__.py
create mode 100644 shopfloor/__manifest__.py
create mode 100644 shopfloor/models/__init__.py
diff --git a/shopfloor/__init__.py b/shopfloor/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
From 7cf6c6ca0f50df0de0b06f6f6c8efa5bcace1a73 Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle
Date: Mon, 20 Jan 2020 16:08:05 +0100
Subject: [PATCH 002/940] add some boilerplate
---
shopfloor/__init__.py | 1 +
shopfloor/__manifest__.py | 20 ++++++++++++++++++++
shopfloor/readme/CONFIGURE.rst | 1 +
shopfloor/readme/CONTRIBUTORS.rst | 3 +++
shopfloor/readme/DESCRIPTION.rst | 1 +
shopfloor/readme/HISTORY.rst | 4 ++++
shopfloor/readme/ROADMAP.rst | 1 +
shopfloor/readme/USAGE.rst | 5 +++++
8 files changed, 36 insertions(+)
create mode 100644 shopfloor/readme/CONFIGURE.rst
create mode 100644 shopfloor/readme/CONTRIBUTORS.rst
create mode 100644 shopfloor/readme/DESCRIPTION.rst
create mode 100644 shopfloor/readme/HISTORY.rst
create mode 100644 shopfloor/readme/ROADMAP.rst
create mode 100644 shopfloor/readme/USAGE.rst
diff --git a/shopfloor/__init__.py b/shopfloor/__init__.py
index e69de29bb2..0650744f6b 100644
--- a/shopfloor/__init__.py
+++ b/shopfloor/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index e69de29bb2..5cc45996a6 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# © 2020 Camptocamp, Akretion, BCIM
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+{
+ "name": "Shopfloor",
+ "summary": "manage warehouse operations with barcode scanners",
+ "version": "13.0.1.0.0",
+ "category": "Inventory",
+ "website": "https://odoo-community.org",
+ "author": "Akretion, BCIM, Camptocamp, Odoo Community Association (OCA)",
+ "licence": "AGPL-3",
+ "application": True,
+ "depends": [
+ "stock",
+ "base_rest",
+ ],
+ "data": [
+ ],
+}
diff --git a/shopfloor/readme/CONFIGURE.rst b/shopfloor/readme/CONFIGURE.rst
new file mode 100644
index 0000000000..8442c179fd
--- /dev/null
+++ b/shopfloor/readme/CONFIGURE.rst
@@ -0,0 +1 @@
+writeme
diff --git a/shopfloor/readme/CONTRIBUTORS.rst b/shopfloor/readme/CONTRIBUTORS.rst
new file mode 100644
index 0000000000..7a55e5ba99
--- /dev/null
+++ b/shopfloor/readme/CONTRIBUTORS.rst
@@ -0,0 +1,3 @@
+* Alexandre Fayolle
+
+ ADD YOURSELF
diff --git a/shopfloor/readme/DESCRIPTION.rst b/shopfloor/readme/DESCRIPTION.rst
new file mode 100644
index 0000000000..756a0db67d
--- /dev/null
+++ b/shopfloor/readme/DESCRIPTION.rst
@@ -0,0 +1 @@
+write me
diff --git a/shopfloor/readme/HISTORY.rst b/shopfloor/readme/HISTORY.rst
new file mode 100644
index 0000000000..dc27ee2605
--- /dev/null
+++ b/shopfloor/readme/HISTORY.rst
@@ -0,0 +1,4 @@
+13.0.1.0.0
+~~~~~~~~~~
+
+First official version.
diff --git a/shopfloor/readme/ROADMAP.rst b/shopfloor/readme/ROADMAP.rst
new file mode 100644
index 0000000000..756a0db67d
--- /dev/null
+++ b/shopfloor/readme/ROADMAP.rst
@@ -0,0 +1 @@
+write me
diff --git a/shopfloor/readme/USAGE.rst b/shopfloor/readme/USAGE.rst
new file mode 100644
index 0000000000..098af7f8df
--- /dev/null
+++ b/shopfloor/readme/USAGE.rst
@@ -0,0 +1,5 @@
+To add your own REST service you must provides at least 2 classes.
+
+* A Component providing the business logic of your service,
+* A Controller to register your service.
+write me
From b92ee110d3b9154280e2e1bf1331325e5d78e619 Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle
Date: Mon, 20 Jan 2020 16:37:49 +0100
Subject: [PATCH 003/940] more boilerplate
---
shopfloor/__manifest__.py | 1 +
shopfloor/controllers/__init__.py | 1 +
shopfloor/controllers/main.py | 3 +++
3 files changed, 5 insertions(+)
create mode 100644 shopfloor/controllers/__init__.py
create mode 100644 shopfloor/controllers/main.py
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 5cc45996a6..c0d07303fe 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,6 +6,7 @@
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
"version": "13.0.1.0.0",
+ "development_status": "Alpha",
"category": "Inventory",
"website": "https://odoo-community.org",
"author": "Akretion, BCIM, Camptocamp, Odoo Community Association (OCA)",
diff --git a/shopfloor/controllers/__init__.py b/shopfloor/controllers/__init__.py
new file mode 100644
index 0000000000..12a7e529b6
--- /dev/null
+++ b/shopfloor/controllers/__init__.py
@@ -0,0 +1 @@
+from . import main
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
new file mode 100644
index 0000000000..1a5fe43b70
--- /dev/null
+++ b/shopfloor/controllers/main.py
@@ -0,0 +1,3 @@
+from odoo import http
+
+
From db6b3688e39efe59ace44b0b2991beca96a6e948 Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle
Date: Mon, 20 Jan 2020 17:11:52 +0100
Subject: [PATCH 004/940] add some models
---
shopfloor/__manifest__.py | 5 +++
shopfloor/models/__init__.py | 3 ++
shopfloor/models/res_users.py | 13 +++++++
shopfloor/models/shopfloor_group.py | 14 ++++++++
shopfloor/models/shopfloor_menu.py | 15 ++++++++
shopfloor/security/ir.model.access.csv | 5 +++
shopfloor/views/menus.xml | 6 ++++
shopfloor/views/res_users.xml | 24 +++++++++++++
shopfloor/views/shopfloor_group.xml | 47 ++++++++++++++++++++++++++
shopfloor/views/shopfloor_menu.xml | 29 ++++++++++++++++
10 files changed, 161 insertions(+)
create mode 100644 shopfloor/models/res_users.py
create mode 100644 shopfloor/models/shopfloor_group.py
create mode 100644 shopfloor/models/shopfloor_menu.py
create mode 100644 shopfloor/security/ir.model.access.csv
create mode 100644 shopfloor/views/menus.xml
create mode 100644 shopfloor/views/res_users.xml
create mode 100644 shopfloor/views/shopfloor_group.xml
create mode 100644 shopfloor/views/shopfloor_menu.xml
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index c0d07303fe..a6b85e88b1 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -17,5 +17,10 @@
"base_rest",
],
"data": [
+ "security/ir.model.access.csv",
+ "views/res_users.xml",
+ "views/shopfloor_group.xml",
+ "views/shopfloor_menu.xml",
+ "views/menus.xml",
],
}
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index e69de29bb2..d8addf9144 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -0,0 +1,3 @@
+from . import shopfloor_menu
+from . import shopfloor_group
+from . import res_users
diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py
new file mode 100644
index 0000000000..fe7db83dd4
--- /dev/null
+++ b/shopfloor/models/res_users.py
@@ -0,0 +1,13 @@
+from odoo import fields, models
+
+
+class ResUsers(models.Model):
+ _inherit = "res.users"
+
+ shopfloor_group_ids = fields.Many2many(
+ 'shopfloor.group',
+ string="Shopfloor groups"
+ )
+ shopfloor_current_process = fields.Char()
+ shopfloor_last_call = fields.Char()
+ shopfloor_picking_id = fields.Many2one('stock.picking')
diff --git a/shopfloor/models/shopfloor_group.py b/shopfloor/models/shopfloor_group.py
new file mode 100644
index 0000000000..9e3ebb6b16
--- /dev/null
+++ b/shopfloor/models/shopfloor_group.py
@@ -0,0 +1,14 @@
+from odoo import fields, models
+
+
+class ShopfloorGroup(models.Model):
+ _name = "shopfloor.group"
+ _description = "Shopfloor group, governs which menu items are visible"
+
+ name = fields.Char(required=True)
+ user_ids = fields.Many2many("res.users", string="Members")
+ menu_ids = fields.Many2many(
+ 'shopfloor.menu',
+ string="Menus",
+ help="Can see these menus",
+ )
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
new file mode 100644
index 0000000000..1e823b4e25
--- /dev/null
+++ b/shopfloor/models/shopfloor_menu.py
@@ -0,0 +1,15 @@
+from odoo import fields, models
+
+
+class ShopfloorMenu(models.Model):
+ _name = 'shopfloor.menu'
+ _description = "Menu displayed in the scanner application"
+ _order = 'sequence'
+
+ name = fields.Char(translate=True)
+ sequence = fields.Integer()
+ group_ids = fields.Many2many(
+ 'shopfloor.group',
+ string="Groups",
+ help="visible for these groups",
+ )
diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv
new file mode 100644
index 0000000000..5ca90ecc73
--- /dev/null
+++ b/shopfloor/security/ir.model.access.csv
@@ -0,0 +1,5 @@
+"id","name","model_id/id","group_id/id","perm_read","perm_write","perm_create","perm_unlink"
+"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu",,1,0,,
+"access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager,1,1,1,1
+"access_shopfloor_group_users","shopfloor group","model_shopfloor_group",,1,0,,
+"access_shopfloor_group_stock_manager","shopfloor group inventory manager","model_shopfloor_group","stock.group_stock_manager,1,1,1,1
diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml
new file mode 100644
index 0000000000..c4a8cbb2f3
--- /dev/null
+++ b/shopfloor/views/menus.xml
@@ -0,0 +1,6 @@
+
+<
+
+
+
+
diff --git a/shopfloor/views/res_users.xml b/shopfloor/views/res_users.xml
new file mode 100644
index 0000000000..2bf16a31f6
--- /dev/null
+++ b/shopfloor/views/res_users.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ Res users Shopfloor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/shopfloor/views/shopfloor_group.xml b/shopfloor/views/shopfloor_group.xml
new file mode 100644
index 0000000000..1aedf5f4a0
--- /dev/null
+++ b/shopfloor/views/shopfloor_group.xml
@@ -0,0 +1,47 @@
+
+
+
+ shopfloor group tree
+
+
+
+
+
+
+
+
+
+ shopfloor group form
+
+
+
+
+
+
+ shopfloor group search
+
+
+
+
+
+
+
+
+ Groups
+ ir.actions.act_window
+ tree,form
+
+
+
diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml
new file mode 100644
index 0000000000..9c50eafa7e
--- /dev/null
+++ b/shopfloor/views/shopfloor_menu.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ shopfloor menu tree
+
+
+
+
+
+
+
+
+
+
+ shopfloor menu search
+
+
+
+
+
+
+
+
+
From 7e1c7e1491111a08b82af5c96e23ccb27ff00278 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 20 Jan 2020 17:20:56 +0100
Subject: [PATCH 005/940] fixup! add some models
---
shopfloor/security/ir.model.access.csv | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv
index 5ca90ecc73..5b805f475d 100644
--- a/shopfloor/security/ir.model.access.csv
+++ b/shopfloor/security/ir.model.access.csv
@@ -1,5 +1,5 @@
"id","name","model_id/id","group_id/id","perm_read","perm_write","perm_create","perm_unlink"
-"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu",,1,0,,
-"access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager,1,1,1,1
-"access_shopfloor_group_users","shopfloor group","model_shopfloor_group",,1,0,,
-"access_shopfloor_group_stock_manager","shopfloor group inventory manager","model_shopfloor_group","stock.group_stock_manager,1,1,1,1
+"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu",,1,0,0,0
+"access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager",1,1,1,1
+"access_shopfloor_group_users","shopfloor group","model_shopfloor_group",,1,0,0,0
+"access_shopfloor_group_stock_manager","shopfloor group inventory manager","model_shopfloor_group","stock.group_stock_manager",1,1,1,1
From 4fa6c4afa13465d727f3b2199b225b229a5a6df2 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Mon, 20 Jan 2020 17:23:12 +0100
Subject: [PATCH 006/940] add start controllers and services
---
shopfloor/__init__.py | 2 ++
shopfloor/controllers/main.py | 10 +++++++++-
shopfloor/services/__init__.py | 1 +
shopfloor/services/shopfloor_service.py | 26 +++++++++++++++++++++++++
4 files changed, 38 insertions(+), 1 deletion(-)
create mode 100644 shopfloor/services/__init__.py
create mode 100644 shopfloor/services/shopfloor_service.py
diff --git a/shopfloor/__init__.py b/shopfloor/__init__.py
index 0650744f6b..c312a8487c 100644
--- a/shopfloor/__init__.py
+++ b/shopfloor/__init__.py
@@ -1 +1,3 @@
+from . import controllers
from . import models
+from . import services
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index 1a5fe43b70..94b9d46c50 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -1,3 +1,11 @@
-from odoo import http
+from odoo.addons.base_rest.controllers import main
+import json
+
+
+class ShopfloorController(main.RestController):
+ _root_path = "/shopfloor/"
+ _collection_name = "shopfloor.services"
+ _default_auth = "api_key"
+
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
new file mode 100644
index 0000000000..86d4e03e8f
--- /dev/null
+++ b/shopfloor/services/__init__.py
@@ -0,0 +1 @@
+from . shopfloor_service
diff --git a/shopfloor/services/shopfloor_service.py b/shopfloor/services/shopfloor_service.py
new file mode 100644
index 0000000000..18146f3461
--- /dev/null
+++ b/shopfloor/services/shopfloor_service.py
@@ -0,0 +1,26 @@
+from odoo.addons.component.core import Component
+
+
+class ShopfloorService(Component):
+ _inherit = "base.rest.service"
+ _name = "shopfloor.service"
+ _collection = "shopfloor.services"
+ _usage = "shopfloor"
+
+ def get_pack(self, pack_name):
+ """
+ Get pack informations
+ """
+ pack = self.env['stock.quant.package'].search([('name', '=', pack_name)])
+ return self._to_json(pack)
+
+ def _validator_search(self):
+ return {"pack_name": {"type": "string", "nullable": False, "required": True}}
+
+ def _to_json(self, pack):
+ res = {
+ "id": pack.id,
+ "name": pack.name,
+ "location": pack.location_id.name,
+ }
+ return res
From 88d0a02d6a0b78732d781bfcb06636a52da1e771 Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle
Date: Mon, 20 Jan 2020 17:28:09 +0100
Subject: [PATCH 007/940] fixup! add some models
---
shopfloor/views/res_users.xml | 1 +
shopfloor/views/shopfloor_group.xml | 4 ++++
shopfloor/views/shopfloor_menu.xml | 3 +++
3 files changed, 8 insertions(+)
diff --git a/shopfloor/views/res_users.xml b/shopfloor/views/res_users.xml
index 2bf16a31f6..7215a23cb6 100644
--- a/shopfloor/views/res_users.xml
+++ b/shopfloor/views/res_users.xml
@@ -3,6 +3,7 @@
Res users Shopfloor
+ res.users
diff --git a/shopfloor/views/shopfloor_group.xml b/shopfloor/views/shopfloor_group.xml
index 1aedf5f4a0..212f672858 100644
--- a/shopfloor/views/shopfloor_group.xml
+++ b/shopfloor/views/shopfloor_group.xml
@@ -2,6 +2,7 @@
shopfloor group tree
+ shopfloor.group
@@ -12,6 +13,7 @@
shopfloor group form
+ shopfloor.group
diff --git a/shopfloor/views/res_users.xml b/shopfloor/views/res_users.xml
deleted file mode 100644
index b1ce6c1e56..0000000000
--- a/shopfloor/views/res_users.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
- Res users Shopfloor
- res.users
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/shopfloor/views/shopfloor_device_views.xml b/shopfloor/views/shopfloor_device_views.xml
new file mode 100644
index 0000000000..2f92f30928
--- /dev/null
+++ b/shopfloor/views/shopfloor_device_views.xml
@@ -0,0 +1,61 @@
+
+
+
+ shopfloor.device tree
+ shopfloor.device
+
+
+
+
+
+
+
+
+
+
+ shopfloor.device form
+ shopfloor.device
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ shopfloor.device search
+ shopfloor.device
+
+
+
+
+
+
+
+
+
+
+
+ Devices
+ shopfloor.device
+ ir.actions.act_window
+ tree,form
+
+
+
From c34a069085bd513f6e50a5d9b5c5066d9782ca79 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 21 Jan 2020 13:55:19 +0100
Subject: [PATCH 019/940] fixup! [IMP] add shopfloor.process
---
shopfloor/security/ir.model.access.csv | 1 +
1 file changed, 1 insertion(+)
diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv
index 2726088b88..6db6c86799 100644
--- a/shopfloor/security/ir.model.access.csv
+++ b/shopfloor/security/ir.model.access.csv
@@ -5,3 +5,4 @@
"access_shopfloor_operation_group_stock_manager","shopfloor operation group inventory manager","model_shopfloor_operation_group","stock.group_stock_manager",1,1,1,1
"access_shopfloor_device_users","shopfloor device","model_shopfloor_device",,1,0,0,0
"access_shopfloor_device_stock_manager","shopfloor device inventory manager","model_shopfloor_device","stock.group_stock_manager",1,1,1,1
+"access_shopfloor_process_users","shopfloor process","model_shopfloor_process",,1,0,0,0
From c1e1558da0ef0036b5f2ee3030f01f16a190de8c Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 21 Jan 2020 14:09:58 +0100
Subject: [PATCH 020/940] Add device restriction per user or operation group
* If a user is assigned on a device, this user can use only this device:
the API returns only this record.
* If a user is not assigned on any device, the devices are filtered by
the operation groups: no group on a device means everyone can use it,
otherwise the user must be in at least one the group assigned to the
device.
---
shopfloor/models/__init__.py | 1 +
shopfloor/models/res_users.py | 12 ++++++++++++
shopfloor/models/shopfloor_device.py | 14 +++++++++++++-
shopfloor/services/device.py | 14 +++++++++++++-
shopfloor/views/shopfloor_device_views.xml | 3 ++-
5 files changed, 41 insertions(+), 3 deletions(-)
create mode 100644 shopfloor/models/res_users.py
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index 7bfd287eba..6878290486 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -3,3 +3,4 @@
from . import shopfloor_process
from . import stock_picking_type
from . import shopfloor_device
+from . import res_users
diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py
new file mode 100644
index 0000000000..e86bb27d2c
--- /dev/null
+++ b/shopfloor/models/res_users.py
@@ -0,0 +1,12 @@
+from odoo import fields, models
+
+
+class ResUsers(models.Model):
+ _inherit = "res.users"
+
+ # in practice, it's a one2one
+ shopfloor_device_ids = fields.One2many(
+ comodel_name="shopfloor.device",
+ inverse_name="user_id",
+ readonly=True,
+ )
diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_device.py
index 986367f936..f3f8ffc56b 100644
--- a/shopfloor/models/shopfloor_device.py
+++ b/shopfloor/models/shopfloor_device.py
@@ -1,4 +1,4 @@
-from odoo import fields, models
+from odoo import api, fields, models
class ShopfloorDevice(models.Model):
@@ -9,6 +9,7 @@ class ShopfloorDevice(models.Model):
warehouse_id = fields.Many2one(
"stock.warehouse",
required=True,
+ default=lambda self: self._default_warehouse_id(),
)
shopfloor_operation_group_ids = fields.Many2many(
"shopfloor.operation.group",
@@ -16,6 +17,7 @@ class ShopfloorDevice(models.Model):
)
user_id = fields.Many2one(
"res.users",
+ copy=False,
help="Optional user using the device. The device will"
"use this configuration when the users logs in the client "
"application."
@@ -23,3 +25,13 @@ class ShopfloorDevice(models.Model):
shopfloor_current_process = fields.Char(readonly=True)
shopfloor_last_call = fields.Char(readonly=True)
shopfloor_picking_id = fields.Many2one('stock.picking', readonly=True)
+
+ _sql_constraints = [
+ ('user_id_uniq', 'unique(user_id)', 'A user can be assigned to only one device.'),
+ ]
+
+ @api.model
+ def _default_warehouse_id(self):
+ wh = self.env['stock.warehouse'].search([])
+ if len(wh) == 1:
+ return wh
diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py
index 74857cd933..5edd4a565b 100644
--- a/shopfloor/services/device.py
+++ b/shopfloor/services/device.py
@@ -1,3 +1,4 @@
+from odoo import fields
from odoo.addons.component.core import Component
@@ -8,13 +9,24 @@ class ShopfloorDevice(Component):
_expose_model = "shopfloor.device"
def search(self, name_fragment=None):
- # TODO filter on shopfloor.group or user in override of _get_base_search_domain
domain = self._get_base_search_domain()
if name_fragment:
domain.append(("name", "ilike", name_fragment))
records = self.env[self._expose_model].search(domain)
return {"size": len(records), "data": self._to_json(records)}
+ def _get_base_search_domain(self):
+ # shopfloor_device_ids is a one2one
+ user = self.env.user
+ assigned_device = fields.first(user.shopfloor_device_ids)
+ if assigned_device:
+ return [("id", "=", assigned_device.id)]
+ return [
+ "|",
+ ("shopfloor_operation_group_ids", "=", False),
+ ("shopfloor_operation_group_ids.user_ids", "=", user.id),
+ ]
+
def _validator_search(self):
return {
"name_fragment": {
diff --git a/shopfloor/views/shopfloor_device_views.xml b/shopfloor/views/shopfloor_device_views.xml
index 2f92f30928..d1805604c7 100644
--- a/shopfloor/views/shopfloor_device_views.xml
+++ b/shopfloor/views/shopfloor_device_views.xml
@@ -4,9 +4,10 @@
shopfloor.device tree
shopfloor.device
-
+
+
From a1341ea56399e8e10c7ec0c981f9a8cf975d042b Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 21 Jan 2020 14:36:06 +0100
Subject: [PATCH 021/940] Ignore validation of search return
---
shopfloor/services/device.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py
index 5edd4a565b..f92d1a18d1 100644
--- a/shopfloor/services/device.py
+++ b/shopfloor/services/device.py
@@ -1,6 +1,8 @@
from odoo import fields
from odoo.addons.component.core import Component
+from odoo.addons.base_rest.components.service import skip_secure_response
+
class ShopfloorDevice(Component):
_inherit = "base.shopfloor.service"
@@ -8,6 +10,7 @@ class ShopfloorDevice(Component):
_usage = "device"
_expose_model = "shopfloor.device"
+ @skip_secure_response
def search(self, name_fragment=None):
domain = self._get_base_search_domain()
if name_fragment:
From deb42863a3f1e6b3e883a54d981b12c970ec6e45 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Tue, 21 Jan 2020 15:14:56 +0100
Subject: [PATCH 022/940] improve putaway service methods and add first
version of tests
---
shopfloor/services/shopfloor_service.py | 67 +++++++++++++++++++++++++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/common.py | 17 +++++++
shopfloor/tests/test_putaway.py | 56 +++++++++++++++++++++
4 files changed, 141 insertions(+)
create mode 100644 shopfloor/tests/__init__.py
create mode 100644 shopfloor/tests/common.py
create mode 100644 shopfloor/tests/test_putaway.py
diff --git a/shopfloor/services/shopfloor_service.py b/shopfloor/services/shopfloor_service.py
index c3e68528b0..3e14289e33 100644
--- a/shopfloor/services/shopfloor_service.py
+++ b/shopfloor/services/shopfloor_service.py
@@ -1,4 +1,5 @@
from odoo.addons.component.core import Component
+from odoo.addons.base_rest.components.service import to_int
# TODO move in a pack service
@@ -7,6 +8,72 @@ class ShopfloorService(Component):
_name = "shopfloor.service"
_usage = "shopfloor"
+ def scan_pack(self, pack_name):
+ pack = self.env['stock.quant.package'].search([('name', '=', pack_name)])
+ company = self.env.user.company_id # FIXME add logic to get proper company
+ # FIXME add logic to get proper warehouse
+ warehouse = self.env['stock.warehouse'].search([])[0]
+ picking_type = warehouse.int_type_id # FIXME add logic to get picking type properly
+ product = pack.quant_ids[0].product_id # FIXME we consider only one product per pack
+ move_vals = {
+ 'picking_type_id': picking_type.id,
+ 'product_id': product.id,
+ 'location_id': pack.location_id.id,
+ 'location_dest_id': picking_type.default_location_dest_id.id,
+ 'name': product.name,
+ 'product_uom': product.uom_id.id,
+ 'product_uom_qty': pack.quant_ids[0].quantity,
+ 'company_id': company.id,
+ }
+ move = self.env['stock.move'].create(move_vals)
+ move._action_confirm()
+ pack_level = self.env['stock.package_level'].create({
+ 'package_id': pack.id,
+ 'move_ids': [(6, 0, [move.id])],
+ 'company_id': company.id,
+ })
+ move.picking_id.action_assign()
+ return_vals = {
+ 'name': pack.name,
+ 'location_name': pack.location_id.name,
+ 'location_dest_name': move.move_line_ids[0].location_dest_id.name,
+ 'product_name': move.name,
+ 'picking_name': move.picking_id.name,
+ 'location_id': pack.location_id.id,
+ 'location_dest_id': move.move_line_ids[0].location_dest_id.id,
+ 'move_id': move.id,
+# 'allow_change_destination': True, #TODO
+ }
+ return return_vals
+
+ def validate(self, move_id, location_name):
+ move = self.env['stock.move'].browse(move_id)
+ dest_location = self.env['stock.location'].search([('name', '=', location_name)])
+ if move.move_line_ids[0].location_dest_id.id != dest_location.id:
+ move.move_line_ids[0].location_dest_id = dest_location.id
+ move.move_line_ids[0].qty_done = move.move_line_ids[0].product_uom_qty
+ move.picking_id.button_validate()
+ return True
+
+ def cancel(self, move_id):
+ move = self.env['stock.move'].browse(move_id)
+ move.picking_id.cancel()
+ return True
+
+ def _validator_validate(self):
+ return {
+ "move_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "location_name": {"type": "string", "nullable": False, "required": True},
+ }
+
+ def _validator_scan_pack(self):
+ return {"pack_name": {"type": "string", "nullable": False, "required": True}}
+
+ def _validator_cancel(self):
+ return {
+ "move_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
def get_pack(self, pack_name):
"""
Get pack informations
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
new file mode 100644
index 0000000000..3a80e1ce96
--- /dev/null
+++ b/shopfloor/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_putaway
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
new file mode 100644
index 0000000000..f85f3c3f94
--- /dev/null
+++ b/shopfloor/tests/common.py
@@ -0,0 +1,17 @@
+from contextlib import contextmanager
+from odoo.addons.base_rest.controllers.main import _PseudoCollection
+from odoo.addons.component.core import WorkContext
+from odoo.tests.common import SavepointCase
+
+
+class CommonCase(SavepointCase):
+
+ @contextmanager
+ def work_on_services(self, **params):
+ params = params or {}
+ collection = _PseudoCollection("shopfloor.service", self.env)
+ yield WorkContext(
+ model_name="rest.service.registration",
+ collection=collection,
+ **params
+ )
diff --git a/shopfloor/tests/test_putaway.py b/shopfloor/tests/test_putaway.py
new file mode 100644
index 0000000000..45b87743d6
--- /dev/null
+++ b/shopfloor/tests/test_putaway.py
@@ -0,0 +1,56 @@
+from .common import CommonCase
+
+
+class PutawayCase(CommonCase):
+
+ def setUp(self, *args, **kwargs):
+ super(PutawayCase, self).setUp(*args, **kwargs)
+ in_location = self.env.ref('stock.stock_location_company').child_ids[0]
+ stock_location = self.env.ref('stock.stock_location_stock')
+ self.productA = self.env['product.product'].create({'name': 'Product A', 'type': 'product'})
+ self.packA = self.env['stock.quant.package'].create({
+ 'location_id': in_location.id
+ })
+ self.quantA = self.env['stock.quant'].create({
+ 'product_id': self.productA.id,
+ 'location_id': in_location.id,
+ 'quantity': 1,
+ 'package_id': self.packA.id,
+ })
+ self.env['stock.putaway.rule'].create({
+ 'product_id': self.productA.id,
+ 'location_in_id': stock_location.id,
+ 'location_out_id': stock_location.child_ids[0].id,
+ })
+ with self.work_on_services(
+ ) as work:
+ self.service = work.component(usage="shopfloor")
+
+ def test_scan_pack(self):
+ pack_name = self.packA.name
+ params = {
+ 'pack_name': pack_name,
+ }
+ response = self.service.dispatch("scan_pack", params=params)
+ move_id = response['move_id']
+ params ={
+ 'move_id': move_id,
+ 'location_name': response['location_dest_name'],
+ }
+ location_dest_id = self.env['stock.location'].search([
+ ('name', '=', params['location_name'])
+ ]).id
+ new_loc_quant = self.env['stock.quant'].search([
+ ('product_id', '=', self.productA.id),
+ ('location_id', '=', location_dest_id)
+ ])
+ self.assertFalse(new_loc_quant)
+ response = self.service.dispatch("validate", params=params)
+ new_loc_quant = self.env['stock.quant'].search([
+ ('product_id', '=', self.productA.id),
+ ('location_id', '=', location_dest_id)
+ ])
+ move = self.env['stock.move'].browse(move_id)
+ self.assertEquals(move.state, 'done')
+ self.assertEquals(self.quantA.quantity, 0)
+ self.assertEquals(new_loc_quant.quantity, move.product_uom_qty)
From 14ef08b9e22d6cb6acbf853274ff8411494d3c57 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 21 Jan 2020 15:16:51 +0100
Subject: [PATCH 023/940] Add demo data for groups and devices
---
shopfloor/__manifest__.py | 2 ++
shopfloor/demo/shopfloor_device_demo.xml | 21 +++++++++++++++++++
.../demo/shopfloor_operation_group_demo.xml | 8 +++++++
shopfloor/models/shopfloor_device.py | 4 +++-
4 files changed, 34 insertions(+), 1 deletion(-)
create mode 100644 shopfloor/demo/shopfloor_device_demo.xml
create mode 100644 shopfloor/demo/shopfloor_operation_group_demo.xml
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 7bae4764a9..5110d5a2eb 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -28,5 +28,7 @@
],
"demo": [
"demo/auth_api_key_demo.xml",
+ "demo/shopfloor_operation_group_demo.xml",
+ "demo/shopfloor_device_demo.xml",
]
}
diff --git a/shopfloor/demo/shopfloor_device_demo.xml b/shopfloor/demo/shopfloor_device_demo.xml
new file mode 100644
index 0000000000..bd9ed3f250
--- /dev/null
+++ b/shopfloor/demo/shopfloor_device_demo.xml
@@ -0,0 +1,21 @@
+
+
+
+ Highbay Truck 1
+
+
+
+
+
+ Highbay Truck 2
+
+
+
+
+
+ Shelf 1
+
+
+
+
+
diff --git a/shopfloor/demo/shopfloor_operation_group_demo.xml b/shopfloor/demo/shopfloor_operation_group_demo.xml
new file mode 100644
index 0000000000..4f8104efb5
--- /dev/null
+++ b/shopfloor/demo/shopfloor_operation_group_demo.xml
@@ -0,0 +1,8 @@
+
+
+
+ HighBay
+
+
+
+
diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_device.py
index f3f8ffc56b..bd32e5c2e1 100644
--- a/shopfloor/models/shopfloor_device.py
+++ b/shopfloor/models/shopfloor_device.py
@@ -13,7 +13,9 @@ class ShopfloorDevice(models.Model):
)
shopfloor_operation_group_ids = fields.Many2many(
"shopfloor.operation.group",
- string="Shopfloor Operation Groups"
+ string="Shopfloor Operation Groups",
+ help="When unset, all users can use the device. When set,"
+ "only users belonging to at least one group can use the device.",
)
user_id = fields.Many2one(
"res.users",
From ee7b19ddddbaada11c5fd1acf28debeb93b47a2a Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 21 Jan 2020 15:36:12 +0100
Subject: [PATCH 024/940] Add demo data for menus and processes
---
shopfloor/__manifest__.py | 2 ++
shopfloor/demo/shopfloor_menu_demo.xml | 9 +++++
shopfloor/demo/shopfloor_process_demo.xml | 7 ++++
shopfloor/models/shopfloor_device.py | 1 +
shopfloor/services/__init__.py | 1 +
shopfloor/services/menu.py | 41 +++++++++++++++++++++++
6 files changed, 61 insertions(+)
create mode 100644 shopfloor/demo/shopfloor_menu_demo.xml
create mode 100644 shopfloor/demo/shopfloor_process_demo.xml
create mode 100644 shopfloor/services/menu.py
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 5110d5a2eb..f94a70f42e 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -28,6 +28,8 @@
],
"demo": [
"demo/auth_api_key_demo.xml",
+ "demo/shopfloor_process_demo.xml",
+ "demo/shopfloor_menu_demo.xml",
"demo/shopfloor_operation_group_demo.xml",
"demo/shopfloor_device_demo.xml",
]
diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml
new file mode 100644
index 0000000000..7d643a0e1f
--- /dev/null
+++ b/shopfloor/demo/shopfloor_menu_demo.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml
new file mode 100644
index 0000000000..30964d7b23
--- /dev/null
+++ b/shopfloor/demo/shopfloor_process_demo.xml
@@ -0,0 +1,7 @@
+
+
+
+ Put-Away Reach Truck
+
+
+
diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_device.py
index bd32e5c2e1..ab47e8ea2f 100644
--- a/shopfloor/models/shopfloor_device.py
+++ b/shopfloor/models/shopfloor_device.py
@@ -11,6 +11,7 @@ class ShopfloorDevice(models.Model):
required=True,
default=lambda self: self._default_warehouse_id(),
)
+ # TODO: remove shopfloor_ prefix
shopfloor_operation_group_ids = fields.Many2many(
"shopfloor.operation.group",
string="Shopfloor Operation Groups",
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 7369a5602f..60ec641820 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -2,3 +2,4 @@
# TODO rename file shop_floor_service to pack
from . import shopfloor_service
from . import device
+from . import menu
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
new file mode 100644
index 0000000000..c61f5c66b6
--- /dev/null
+++ b/shopfloor/services/menu.py
@@ -0,0 +1,41 @@
+from odoo.addons.component.core import Component
+
+from odoo.addons.base_rest.components.service import skip_secure_response
+
+
+class ShopfloorMenu(Component):
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.menu"
+ _usage = "menu"
+ _expose_model = "shopfloor.menu"
+
+ @skip_secure_response
+ def search(self, name_fragment=None):
+ domain = self._get_base_search_domain()
+ if name_fragment:
+ domain.append(("name", "ilike", name_fragment))
+ records = self.env[self._expose_model].search(domain)
+ return {"size": len(records), "data": self._to_json(records)}
+
+ def _get_base_search_domain(self):
+ user = self.env.user
+ return [
+ "|",
+ ("operation_group_ids", "=", False),
+ ("operation_group_ids.user_ids", "=", user.id),
+ ]
+
+ def _validator_search(self):
+ return {
+ "name_fragment": {
+ "type": "string",
+ "nullable": True,
+ "required": False,
+ }
+ }
+
+ def _convert_one_record(self, record):
+ return {
+ "id": record.id,
+ "name": record.name,
+ }
From 26f44c6377cc1485135a6d2cba3b35b1e2d3e491 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 21 Jan 2020 15:43:13 +0100
Subject: [PATCH 025/940] Rename field (sorry, you'll have to rebuild your db)
---
shopfloor/models/shopfloor_device.py | 3 +--
shopfloor/services/device.py | 4 ++--
shopfloor/views/shopfloor_device_views.xml | 6 +++---
3 files changed, 6 insertions(+), 7 deletions(-)
diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_device.py
index ab47e8ea2f..f84c78322e 100644
--- a/shopfloor/models/shopfloor_device.py
+++ b/shopfloor/models/shopfloor_device.py
@@ -11,8 +11,7 @@ class ShopfloorDevice(models.Model):
required=True,
default=lambda self: self._default_warehouse_id(),
)
- # TODO: remove shopfloor_ prefix
- shopfloor_operation_group_ids = fields.Many2many(
+ operation_group_ids = fields.Many2many(
"shopfloor.operation.group",
string="Shopfloor Operation Groups",
help="When unset, all users can use the device. When set,"
diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py
index f92d1a18d1..c53060838e 100644
--- a/shopfloor/services/device.py
+++ b/shopfloor/services/device.py
@@ -26,8 +26,8 @@ def _get_base_search_domain(self):
return [("id", "=", assigned_device.id)]
return [
"|",
- ("shopfloor_operation_group_ids", "=", False),
- ("shopfloor_operation_group_ids.user_ids", "=", user.id),
+ ("operation_group_ids", "=", False),
+ ("operation_group_ids.user_ids", "=", user.id),
]
def _validator_search(self):
diff --git a/shopfloor/views/shopfloor_device_views.xml b/shopfloor/views/shopfloor_device_views.xml
index d1805604c7..1f6dc4cc25 100644
--- a/shopfloor/views/shopfloor_device_views.xml
+++ b/shopfloor/views/shopfloor_device_views.xml
@@ -7,7 +7,7 @@
-
+
@@ -25,7 +25,7 @@
-
+
@@ -47,7 +47,7 @@
-
+
From 8d5cb533060643c4cf87d87132ba345d9b4c0165 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Tue, 21 Jan 2020 16:03:21 +0100
Subject: [PATCH 026/940] [IMP] add validator return for search shopfloor
device
---
shopfloor/services/device.py | 31 +++++++++++++++++++++++++++----
1 file changed, 27 insertions(+), 4 deletions(-)
diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py
index c53060838e..8e6ede204a 100644
--- a/shopfloor/services/device.py
+++ b/shopfloor/services/device.py
@@ -1,7 +1,7 @@
from odoo import fields
from odoo.addons.component.core import Component
-from odoo.addons.base_rest.components.service import skip_secure_response
+from odoo.addons.base_rest.components.service import to_int
class ShopfloorDevice(Component):
@@ -10,7 +10,6 @@ class ShopfloorDevice(Component):
_usage = "device"
_expose_model = "shopfloor.device"
- @skip_secure_response
def search(self, name_fragment=None):
domain = self._get_base_search_domain()
if name_fragment:
@@ -39,10 +38,34 @@ def _validator_search(self):
}
}
+ def _validator_return_search(self):
+ return {
+ "size": {"coerce": to_int, "required": True, "type": "integer"},
+ "data": {
+ "type": "list",
+ "schema": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "warehouse": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ }
+ }
+ }
+ }
+ }
+ }
+
def _convert_one_record(self, record):
return {
"id": record.id,
"name": record.name,
- "warehouse_id": record.warehouse_id.id,
- "warehouse": record.warehouse_id.name,
+ "warehouse": {
+ "id": record.warehouse_id.id,
+ "name": record.warehouse_id.name,
+ }
}
From b2c2f1635e64aef2ce2dbe1fb158c8c6a24c5a10 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 21 Jan 2020 16:16:30 +0100
Subject: [PATCH 027/940] Add schema validation for menu /search output
---
shopfloor/services/menu.py | 42 ++++++++++++++++++++++----------------
1 file changed, 24 insertions(+), 18 deletions(-)
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index c61f5c66b6..c4270d1b9a 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -1,7 +1,6 @@
+from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
-from odoo.addons.base_rest.components.service import skip_secure_response
-
class ShopfloorMenu(Component):
_inherit = "base.shopfloor.service"
@@ -9,14 +8,6 @@ class ShopfloorMenu(Component):
_usage = "menu"
_expose_model = "shopfloor.menu"
- @skip_secure_response
- def search(self, name_fragment=None):
- domain = self._get_base_search_domain()
- if name_fragment:
- domain.append(("name", "ilike", name_fragment))
- records = self.env[self._expose_model].search(domain)
- return {"size": len(records), "data": self._to_json(records)}
-
def _get_base_search_domain(self):
user = self.env.user
return [
@@ -25,17 +16,32 @@ def _get_base_search_domain(self):
("operation_group_ids.user_ids", "=", user.id),
]
+ def search(self, name_fragment=None):
+ domain = self._get_base_search_domain()
+ if name_fragment:
+ domain.append(("name", "ilike", name_fragment))
+ records = self.env[self._expose_model].search(domain)
+ return {"size": len(records), "data": self._to_json(records)}
+
def _validator_search(self):
return {
- "name_fragment": {
- "type": "string",
- "nullable": True,
- "required": False,
- }
+ "name_fragment": {"type": "string", "nullable": True, "required": False}
}
- def _convert_one_record(self, record):
+ def _validator_return_search(self):
return {
- "id": record.id,
- "name": record.name,
+ "size": {"coerce": to_int, "required": True, "type": "integer"},
+ "data": {
+ "type": "list",
+ "schema": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ },
}
+
+ def _convert_one_record(self, record):
+ return {"id": record.id, "name": record.name}
From cdf4c066802d085770f7948aa2e47e7986706ba7 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Tue, 21 Jan 2020 16:24:28 +0100
Subject: [PATCH 028/940] add process name and menu in request header
---
shopfloor/controllers/main.py | 22 +++++++++++++++++++++-
shopfloor/services/service.py | 22 ++++++++++++++++++++--
2 files changed, 41 insertions(+), 3 deletions(-)
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index 94b9d46c50..4a95642ab9 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -1,5 +1,6 @@
from odoo.addons.base_rest.controllers import main
-import json
+from odoo.exceptions import MissingError
+from odoo.http import request
class ShopfloorController(main.RestController):
@@ -7,5 +8,24 @@ class ShopfloorController(main.RestController):
_collection_name = "shopfloor.services"
_default_auth = "api_key"
+ @classmethod
+ def _get_process_from_headers(cls, headers):
+ process_name = headers.get("HTTP_SERVICE_CTX_PROCESS_NAME")
+ return process_name
+ @classmethod
+ def _get_process_menu_from_headers(cls, headers):
+ process_menu = headers.get("HTTP_SERVICE_CTX_PROCESS_MENU")
+ return process_menu
+ def _get_component_context(self):
+ """
+ This method adds the component context:
+ * the process name
+ * the process menu
+ """
+ res = super(ShopfloorController, self)._get_component_context()
+ headers = request.httprequest.environ
+ res["process_name"] = self._get_process_from_headers(headers)
+ res["process_menu"] = self._get_process_menu_from_headers(headers)
+ return res
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index f52894f558..7d6cdb05dd 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -40,7 +40,7 @@ def _get_openapi_default_parameters(self):
demo_api_key = self.env.ref(
"shopfloor.api_key_demo", raise_if_not_found=False
).key
- defaults.append(
+ defaults.extend([
{
"name": "API-KEY",
"in": "header",
@@ -49,6 +49,24 @@ def _get_openapi_default_parameters(self):
"schema": {"type": "string"},
"style": "simple",
"value": demo_api_key,
+ },
+ {
+ "name": "SERVICE_CTX_PROCESS_NAME",
+ "in": "header",
+ "description": "Name of the current process",
+ "required": True,
+ "schema": {"type": "string"},
+ "style": "simple",
+ "value": "Put-Away Reach Truck",
+ },
+ {
+ "name": "SERVICE_CTX_PROCESS_MENU",
+ "in": "header",
+ "description": "Name of the current process menu",
+ "required": True,
+ "schema": {"type": "string"},
+ "style": "simple",
+ "value": "Put-Away Reach Truck",
}
- )
+ ])
return defaults
From 4fd93563f3795897aba6658957f628d9379fceaa Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 21 Jan 2020 16:46:15 +0100
Subject: [PATCH 029/940] Extract pack service in rest api
---
shopfloor/services/__init__.py | 3 +-
shopfloor/services/pack.py | 124 ++++++++++++++++++++++++
shopfloor/services/shopfloor_service.py | 93 ------------------
3 files changed, 125 insertions(+), 95 deletions(-)
create mode 100644 shopfloor/services/pack.py
delete mode 100644 shopfloor/services/shopfloor_service.py
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 60ec641820..1fa6f94a43 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -1,5 +1,4 @@
from . import service
-# TODO rename file shop_floor_service to pack
-from . import shopfloor_service
from . import device
from . import menu
+from . import pack
diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py
new file mode 100644
index 0000000000..e0dcd1980e
--- /dev/null
+++ b/shopfloor/services/pack.py
@@ -0,0 +1,124 @@
+from odoo.addons.base_rest.components.service import to_int
+from odoo.addons.component.core import Component
+
+
+class ShopfloorPack(Component):
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.pack"
+ _usage = "pack"
+
+ def scan(self, pack_name):
+ pack = self.env["stock.quant.package"].search([("name", "=", pack_name)])
+ company = self.env.user.company_id # FIXME add logic to get proper company
+ # FIXME add logic to get proper warehouse
+ warehouse = self.env["stock.warehouse"].search([])[0]
+ picking_type = (
+ warehouse.int_type_id
+ ) # FIXME add logic to get picking type properly
+ product = pack.quant_ids[
+ 0
+ ].product_id # FIXME we consider only one product per pack
+ move_vals = {
+ "picking_type_id": picking_type.id,
+ "product_id": product.id,
+ "location_id": pack.location_id.id,
+ "location_dest_id": picking_type.default_location_dest_id.id,
+ "name": product.name,
+ "product_uom": product.uom_id.id,
+ "product_uom_qty": pack.quant_ids[0].quantity,
+ "company_id": company.id,
+ }
+ move = self.env["stock.move"].create(move_vals)
+ move._action_confirm()
+ self.env["stock.package_level"].create(
+ {
+ "package_id": pack.id,
+ "move_ids": [(6, 0, [move.id])],
+ "company_id": company.id,
+ }
+ )
+ move.picking_id.action_assign()
+ return_vals = {
+ "name": pack.name,
+ "location_name": pack.location_id.name,
+ "location_dest_name": move.move_line_ids[0].location_dest_id.name,
+ "product_name": move.name,
+ "picking_name": move.picking_id.name,
+ "location_id": pack.location_id.id,
+ "location_dest_id": move.move_line_ids[0].location_dest_id.id,
+ "move_id": move.id,
+ # 'allow_change_destination': True, #TODO
+ }
+ return return_vals
+
+ def validate(self, move_id, location_name):
+ move = self.env["stock.move"].browse(move_id)
+ dest_location = self.env["stock.location"].search(
+ [("name", "=", location_name)]
+ )
+ if move.move_line_ids[0].location_dest_id.id != dest_location.id:
+ move.move_line_ids[0].location_dest_id = dest_location.id
+ move.move_line_ids[0].qty_done = move.move_line_ids[0].product_uom_qty
+ move.picking_id.button_validate()
+ return True
+
+ def cancel(self, move_id):
+ move = self.env["stock.move"].browse(move_id)
+ move.picking_id.cancel()
+ return True
+
+ def _validator_cancel(self):
+ return {"move_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def _validator_validate(self):
+ return {
+ "move_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "location_name": {"type": "string", "nullable": False, "required": True},
+ }
+
+ def _validator_scan(self):
+ return {"pack_name": {"type": "string", "nullable": False, "required": True}}
+
+ def _validator_return_scan(self):
+ return {"data": self._record_return_schema}
+
+ def get_by_name(self, pack_name):
+ """
+ Get pack informations
+ """
+ pack = self.env["stock.quant.package"].search(
+ [("name", "=", pack_name)],
+ # TODO, is it what we want? error if not found?
+ limit=1,
+ )
+ return self._to_json(pack)[:1]
+
+ def _validator_get_by_name(self):
+ return {"pack_name": {"type": "string", "nullable": False, "required": True}}
+
+ def _validator_return_get_by_name(self):
+ return {"data": self._record_return_schema}
+
+ def _convert_one_record(self, record):
+ return {
+ "id": record.id,
+ "name": record.name,
+ "location": {"id": record.location_id.id, "name": record.location_id.name},
+ }
+
+ @property
+ def _record_return_schema(self):
+ return {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "location": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ },
+ }
diff --git a/shopfloor/services/shopfloor_service.py b/shopfloor/services/shopfloor_service.py
deleted file mode 100644
index 3e14289e33..0000000000
--- a/shopfloor/services/shopfloor_service.py
+++ /dev/null
@@ -1,93 +0,0 @@
-from odoo.addons.component.core import Component
-from odoo.addons.base_rest.components.service import to_int
-
-
-# TODO move in a pack service
-class ShopfloorService(Component):
- _inherit = "base.shopfloor.service"
- _name = "shopfloor.service"
- _usage = "shopfloor"
-
- def scan_pack(self, pack_name):
- pack = self.env['stock.quant.package'].search([('name', '=', pack_name)])
- company = self.env.user.company_id # FIXME add logic to get proper company
- # FIXME add logic to get proper warehouse
- warehouse = self.env['stock.warehouse'].search([])[0]
- picking_type = warehouse.int_type_id # FIXME add logic to get picking type properly
- product = pack.quant_ids[0].product_id # FIXME we consider only one product per pack
- move_vals = {
- 'picking_type_id': picking_type.id,
- 'product_id': product.id,
- 'location_id': pack.location_id.id,
- 'location_dest_id': picking_type.default_location_dest_id.id,
- 'name': product.name,
- 'product_uom': product.uom_id.id,
- 'product_uom_qty': pack.quant_ids[0].quantity,
- 'company_id': company.id,
- }
- move = self.env['stock.move'].create(move_vals)
- move._action_confirm()
- pack_level = self.env['stock.package_level'].create({
- 'package_id': pack.id,
- 'move_ids': [(6, 0, [move.id])],
- 'company_id': company.id,
- })
- move.picking_id.action_assign()
- return_vals = {
- 'name': pack.name,
- 'location_name': pack.location_id.name,
- 'location_dest_name': move.move_line_ids[0].location_dest_id.name,
- 'product_name': move.name,
- 'picking_name': move.picking_id.name,
- 'location_id': pack.location_id.id,
- 'location_dest_id': move.move_line_ids[0].location_dest_id.id,
- 'move_id': move.id,
-# 'allow_change_destination': True, #TODO
- }
- return return_vals
-
- def validate(self, move_id, location_name):
- move = self.env['stock.move'].browse(move_id)
- dest_location = self.env['stock.location'].search([('name', '=', location_name)])
- if move.move_line_ids[0].location_dest_id.id != dest_location.id:
- move.move_line_ids[0].location_dest_id = dest_location.id
- move.move_line_ids[0].qty_done = move.move_line_ids[0].product_uom_qty
- move.picking_id.button_validate()
- return True
-
- def cancel(self, move_id):
- move = self.env['stock.move'].browse(move_id)
- move.picking_id.cancel()
- return True
-
- def _validator_validate(self):
- return {
- "move_id": {"coerce": to_int, "required": True, "type": "integer"},
- "location_name": {"type": "string", "nullable": False, "required": True},
- }
-
- def _validator_scan_pack(self):
- return {"pack_name": {"type": "string", "nullable": False, "required": True}}
-
- def _validator_cancel(self):
- return {
- "move_id": {"coerce": to_int, "required": True, "type": "integer"},
- }
-
- def get_pack(self, pack_name):
- """
- Get pack informations
- """
- pack = self.env['stock.quant.package'].search([('name', '=', pack_name)])
- return self._to_json(pack)
-
- def _validator_get_pack(self):
- return {"pack_name": {"type": "string", "nullable": False, "required": True}}
-
- def _to_json(self, pack):
- res = {
- "id": pack.id,
- "name": pack.name,
- "location": pack.location_id.name,
- }
- return res
From d2e67927f64c61f7affdc0e74c74d763f1d726a0 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 21 Jan 2020 16:48:38 +0100
Subject: [PATCH 030/940] Add docstrings on rest methods (they are shown in
swagger)
---
shopfloor/services/device.py | 33 +++++++++++++++++++--------------
shopfloor/services/menu.py | 1 +
shopfloor/services/pack.py | 1 +
3 files changed, 21 insertions(+), 14 deletions(-)
diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py
index 8e6ede204a..b2609f4245 100644
--- a/shopfloor/services/device.py
+++ b/shopfloor/services/device.py
@@ -1,7 +1,7 @@
from odoo import fields
-from odoo.addons.component.core import Component
from odoo.addons.base_rest.components.service import to_int
+from odoo.addons.component.core import Component
class ShopfloorDevice(Component):
@@ -11,6 +11,7 @@ class ShopfloorDevice(Component):
_expose_model = "shopfloor.device"
def search(self, name_fragment=None):
+ """List available devices for current user"""
domain = self._get_base_search_domain()
if name_fragment:
domain.append(("name", "ilike", name_fragment))
@@ -31,11 +32,7 @@ def _get_base_search_domain(self):
def _validator_search(self):
return {
- "name_fragment": {
- "type": "string",
- "nullable": True,
- "required": False,
- }
+ "name_fragment": {"type": "string", "nullable": True, "required": False}
}
def _validator_return_search(self):
@@ -51,13 +48,21 @@ def _validator_return_search(self):
"warehouse": {
"type": "dict",
"schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- }
- }
- }
- }
- }
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ },
+ },
+ },
}
def _convert_one_record(self, record):
@@ -67,5 +72,5 @@ def _convert_one_record(self, record):
"warehouse": {
"id": record.warehouse_id.id,
"name": record.warehouse_id.name,
- }
+ },
}
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index c4270d1b9a..3fdf5182a0 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -17,6 +17,7 @@ def _get_base_search_domain(self):
]
def search(self, name_fragment=None):
+ """List available menu entries for current user"""
domain = self._get_base_search_domain()
if name_fragment:
domain.append(("name", "ilike", name_fragment))
diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py
index e0dcd1980e..bac63d79bb 100644
--- a/shopfloor/services/pack.py
+++ b/shopfloor/services/pack.py
@@ -8,6 +8,7 @@ class ShopfloorPack(Component):
_usage = "pack"
def scan(self, pack_name):
+ """Scan a pack barcode"""
pack = self.env["stock.quant.package"].search([("name", "=", pack_name)])
company = self.env.user.company_id # FIXME add logic to get proper company
# FIXME add logic to get proper warehouse
From 44c5d5b15a58f55fe3d3be92f645755538474b12 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Tue, 21 Jan 2020 16:56:31 +0100
Subject: [PATCH 031/940] ref header params make it extendable
---
shopfloor/controllers/main.py | 16 ++++------------
1 file changed, 4 insertions(+), 12 deletions(-)
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index 4a95642ab9..5909552712 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -8,16 +8,6 @@ class ShopfloorController(main.RestController):
_collection_name = "shopfloor.services"
_default_auth = "api_key"
- @classmethod
- def _get_process_from_headers(cls, headers):
- process_name = headers.get("HTTP_SERVICE_CTX_PROCESS_NAME")
- return process_name
-
- @classmethod
- def _get_process_menu_from_headers(cls, headers):
- process_menu = headers.get("HTTP_SERVICE_CTX_PROCESS_MENU")
- return process_menu
-
def _get_component_context(self):
"""
This method adds the component context:
@@ -26,6 +16,8 @@ def _get_component_context(self):
"""
res = super(ShopfloorController, self)._get_component_context()
headers = request.httprequest.environ
- res["process_name"] = self._get_process_from_headers(headers)
- res["process_menu"] = self._get_process_menu_from_headers(headers)
+ for k, v in headers.items():
+ if k.startswith('HTTP_SERVICE_CTX_'):
+ key_name = k[17:].lower()
+ res[key_name] = v
return res
From 17e2178229cd9776be53eb222a84f9ef48bb9181 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 21 Jan 2020 17:14:42 +0100
Subject: [PATCH 032/940] Fix service test
---
shopfloor/controllers/main.py | 6 +--
shopfloor/services/pack.py | 7 ++-
shopfloor/services/service.py | 70 +++++++++++++------------
shopfloor/tests/test_putaway.py | 92 ++++++++++++++++++---------------
4 files changed, 91 insertions(+), 84 deletions(-)
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index 5909552712..4df8196e6e 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -1,11 +1,11 @@
-from odoo.addons.base_rest.controllers import main
-from odoo.exceptions import MissingError
from odoo.http import request
+from odoo.addons.base_rest.controllers import main
+
class ShopfloorController(main.RestController):
_root_path = "/shopfloor/"
- _collection_name = "shopfloor.services"
+ _collection_name = "shopfloor.service"
_default_auth = "api_key"
def _get_component_context(self):
diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py
index bac63d79bb..bf098599a3 100644
--- a/shopfloor/services/pack.py
+++ b/shopfloor/services/pack.py
@@ -1,4 +1,4 @@
-from odoo.addons.base_rest.components.service import to_int
+from odoo.addons.base_rest.components.service import skip_secure_response, to_int
from odoo.addons.component.core import Component
@@ -7,6 +7,8 @@ class ShopfloorPack(Component):
_name = "shopfloor.pack"
_usage = "pack"
+ # TODO define the return schema and add the validator method
+ @skip_secure_response
def scan(self, pack_name):
"""Scan a pack barcode"""
pack = self.env["stock.quant.package"].search([("name", "=", pack_name)])
@@ -80,9 +82,6 @@ def _validator_validate(self):
def _validator_scan(self):
return {"pack_name": {"type": "string", "nullable": False, "required": True}}
- def _validator_return_scan(self):
- return {"data": self._record_return_schema}
-
def get_by_name(self, pack_name):
"""
Get pack informations
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 7d6cdb05dd..d19ba3db27 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -1,13 +1,14 @@
from odoo import _
+from odoo.exceptions import MissingError
from odoo.osv import expression
+
from odoo.addons.component.core import AbstractComponent
-from odoo.exceptions import MissingError
class BaseShopfloorService(AbstractComponent):
_inherit = "base.rest.service"
_name = "base.shopfloor.service"
- _collection = "shopfloor.services"
+ _collection = "shopfloor.service"
_expose_model = None
def _get(self, _id):
@@ -16,8 +17,7 @@ def _get(self, _id):
record = self.env[self._expose_model].search(domain)
if not record:
raise MissingError(
- _("The record %s %s does not exist")
- % (self._expose_model, _id)
+ _("The record %s %s does not exist") % (self._expose_model, _id)
)
else:
return record
@@ -25,7 +25,7 @@ def _get(self, _id):
def _get_base_search_domain(self):
return []
- def _convert_one_record(record):
+ def _convert_one_record(self, record):
"""To implement in service Components"""
return {}
@@ -40,33 +40,35 @@ def _get_openapi_default_parameters(self):
demo_api_key = self.env.ref(
"shopfloor.api_key_demo", raise_if_not_found=False
).key
- defaults.extend([
- {
- "name": "API-KEY",
- "in": "header",
- "description": "API key for Authorization",
- "required": True,
- "schema": {"type": "string"},
- "style": "simple",
- "value": demo_api_key,
- },
- {
- "name": "SERVICE_CTX_PROCESS_NAME",
- "in": "header",
- "description": "Name of the current process",
- "required": True,
- "schema": {"type": "string"},
- "style": "simple",
- "value": "Put-Away Reach Truck",
- },
- {
- "name": "SERVICE_CTX_PROCESS_MENU",
- "in": "header",
- "description": "Name of the current process menu",
- "required": True,
- "schema": {"type": "string"},
- "style": "simple",
- "value": "Put-Away Reach Truck",
- }
- ])
+ defaults.extend(
+ [
+ {
+ "name": "API-KEY",
+ "in": "header",
+ "description": "API key for Authorization",
+ "required": True,
+ "schema": {"type": "string"},
+ "style": "simple",
+ "value": demo_api_key,
+ },
+ {
+ "name": "SERVICE_CTX_PROCESS_NAME",
+ "in": "header",
+ "description": "Name of the current process",
+ "required": True,
+ "schema": {"type": "string"},
+ "style": "simple",
+ "value": "Put-Away Reach Truck",
+ },
+ {
+ "name": "SERVICE_CTX_PROCESS_MENU",
+ "in": "header",
+ "description": "Name of the current process menu",
+ "required": True,
+ "schema": {"type": "string"},
+ "style": "simple",
+ "value": "Put-Away Reach Truck",
+ },
+ ]
+ )
return defaults
diff --git a/shopfloor/tests/test_putaway.py b/shopfloor/tests/test_putaway.py
index 45b87743d6..53c325cb0c 100644
--- a/shopfloor/tests/test_putaway.py
+++ b/shopfloor/tests/test_putaway.py
@@ -2,55 +2,61 @@
class PutawayCase(CommonCase):
-
def setUp(self, *args, **kwargs):
super(PutawayCase, self).setUp(*args, **kwargs)
- in_location = self.env.ref('stock.stock_location_company').child_ids[0]
- stock_location = self.env.ref('stock.stock_location_stock')
- self.productA = self.env['product.product'].create({'name': 'Product A', 'type': 'product'})
- self.packA = self.env['stock.quant.package'].create({
- 'location_id': in_location.id
- })
- self.quantA = self.env['stock.quant'].create({
- 'product_id': self.productA.id,
- 'location_id': in_location.id,
- 'quantity': 1,
- 'package_id': self.packA.id,
- })
- self.env['stock.putaway.rule'].create({
- 'product_id': self.productA.id,
- 'location_in_id': stock_location.id,
- 'location_out_id': stock_location.child_ids[0].id,
- })
- with self.work_on_services(
- ) as work:
- self.service = work.component(usage="shopfloor")
+ in_location = self.env.ref("stock.stock_location_company").child_ids[0]
+ stock_location = self.env.ref("stock.stock_location_stock")
+ self.productA = self.env["product.product"].create(
+ {"name": "Product A", "type": "product"}
+ )
+ self.packA = self.env["stock.quant.package"].create(
+ {"location_id": in_location.id}
+ )
+ self.quantA = self.env["stock.quant"].create(
+ {
+ "product_id": self.productA.id,
+ "location_id": in_location.id,
+ "quantity": 1,
+ "package_id": self.packA.id,
+ }
+ )
+ self.env["stock.putaway.rule"].create(
+ {
+ "product_id": self.productA.id,
+ "location_in_id": stock_location.id,
+ "location_out_id": stock_location.child_ids[0].id,
+ }
+ )
+
+ with self.work_on_services() as work:
+ self.service = work.component(usage="pack")
def test_scan_pack(self):
pack_name = self.packA.name
- params = {
- 'pack_name': pack_name,
- }
- response = self.service.dispatch("scan_pack", params=params)
- move_id = response['move_id']
- params ={
- 'move_id': move_id,
- 'location_name': response['location_dest_name'],
- }
- location_dest_id = self.env['stock.location'].search([
- ('name', '=', params['location_name'])
- ]).id
- new_loc_quant = self.env['stock.quant'].search([
- ('product_id', '=', self.productA.id),
- ('location_id', '=', location_dest_id)
- ])
+ params = {"pack_name": pack_name}
+ response = self.service.dispatch("scan", params=params)
+ move_id = response["move_id"]
+ params = {"move_id": move_id, "location_name": response["location_dest_name"]}
+ location_dest_id = (
+ self.env["stock.location"]
+ .search([("name", "=", params["location_name"])])
+ .id
+ )
+ new_loc_quant = self.env["stock.quant"].search(
+ [
+ ("product_id", "=", self.productA.id),
+ ("location_id", "=", location_dest_id),
+ ]
+ )
self.assertFalse(new_loc_quant)
response = self.service.dispatch("validate", params=params)
- new_loc_quant = self.env['stock.quant'].search([
- ('product_id', '=', self.productA.id),
- ('location_id', '=', location_dest_id)
- ])
- move = self.env['stock.move'].browse(move_id)
- self.assertEquals(move.state, 'done')
+ new_loc_quant = self.env["stock.quant"].search(
+ [
+ ("product_id", "=", self.productA.id),
+ ("location_id", "=", location_dest_id),
+ ]
+ )
+ move = self.env["stock.move"].browse(move_id)
+ self.assertEquals(move.state, "done")
self.assertEquals(self.quantA.quantity, 0)
self.assertEquals(new_loc_quant.quantity, move.product_uom_qty)
From 729d4e7fd7c9a4af374bc7cb5bc35912c45111cf Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 21 Jan 2020 17:20:33 +0100
Subject: [PATCH 033/940] Apply pre-commit run -a
---
shopfloor/__manifest__.py | 11 +++--------
shopfloor/controllers/main.py | 2 +-
shopfloor/models/res_users.py | 4 +---
shopfloor/models/shopfloor_device.py | 12 ++++++++----
shopfloor/models/shopfloor_menu.py | 10 ++++------
shopfloor/models/shopfloor_operation_group.py | 4 +---
shopfloor/models/shopfloor_process.py | 2 +-
shopfloor/models/stock_picking_type.py | 4 ++--
shopfloor/tests/common.py | 9 ++++-----
9 files changed, 25 insertions(+), 33 deletions(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index f94a70f42e..b4043333aa 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# © 2020 Camptocamp, Akretion, BCIM
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
@@ -10,13 +9,9 @@
"category": "Inventory",
"website": "https://odoo-community.org",
"author": "Akretion, BCIM, Camptocamp, Odoo Community Association (OCA)",
- "licence": "AGPL-3",
+ "license": "AGPL-3",
"application": True,
- "depends": [
- "stock",
- "base_rest",
- "auth_api_key",
- ],
+ "depends": ["stock", "base_rest", "auth_api_key"],
"data": [
"security/ir.model.access.csv",
"views/shopfloor_operation_group.xml",
@@ -32,5 +27,5 @@
"demo/shopfloor_menu_demo.xml",
"demo/shopfloor_operation_group_demo.xml",
"demo/shopfloor_device_demo.xml",
- ]
+ ],
}
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index 4df8196e6e..34be9aae06 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -17,7 +17,7 @@ def _get_component_context(self):
res = super(ShopfloorController, self)._get_component_context()
headers = request.httprequest.environ
for k, v in headers.items():
- if k.startswith('HTTP_SERVICE_CTX_'):
+ if k.startswith("HTTP_SERVICE_CTX_"):
key_name = k[17:].lower()
res[key_name] = v
return res
diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py
index e86bb27d2c..6838b582ee 100644
--- a/shopfloor/models/res_users.py
+++ b/shopfloor/models/res_users.py
@@ -6,7 +6,5 @@ class ResUsers(models.Model):
# in practice, it's a one2one
shopfloor_device_ids = fields.One2many(
- comodel_name="shopfloor.device",
- inverse_name="user_id",
- readonly=True,
+ comodel_name="shopfloor.device", inverse_name="user_id", readonly=True
)
diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_device.py
index f84c78322e..25acbe2dc6 100644
--- a/shopfloor/models/shopfloor_device.py
+++ b/shopfloor/models/shopfloor_device.py
@@ -22,18 +22,22 @@ class ShopfloorDevice(models.Model):
copy=False,
help="Optional user using the device. The device will"
"use this configuration when the users logs in the client "
- "application."
+ "application.",
)
shopfloor_current_process = fields.Char(readonly=True)
shopfloor_last_call = fields.Char(readonly=True)
- shopfloor_picking_id = fields.Many2one('stock.picking', readonly=True)
+ shopfloor_picking_id = fields.Many2one("stock.picking", readonly=True)
_sql_constraints = [
- ('user_id_uniq', 'unique(user_id)', 'A user can be assigned to only one device.'),
+ (
+ "user_id_uniq",
+ "unique(user_id)",
+ "A user can be assigned to only one device.",
+ )
]
@api.model
def _default_warehouse_id(self):
- wh = self.env['stock.warehouse'].search([])
+ wh = self.env["stock.warehouse"].search([])
if len(wh) == 1:
return wh
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index 48f836bd37..f95d37c382 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -2,15 +2,13 @@
class ShopfloorMenu(models.Model):
- _name = 'shopfloor.menu'
+ _name = "shopfloor.menu"
_description = "Menu displayed in the scanner application"
- _order = 'sequence'
+ _order = "sequence"
name = fields.Char(translate=True)
sequence = fields.Integer()
operation_group_ids = fields.Many2many(
- 'shopfloor.operation.group',
- string="Groups",
- help="visible for these groups",
+ "shopfloor.operation.group", string="Groups", help="visible for these groups"
)
- process_id = fields.Many2one('shopfloor.process', name="Process")
+ process_id = fields.Many2one("shopfloor.process", name="Process")
diff --git a/shopfloor/models/shopfloor_operation_group.py b/shopfloor/models/shopfloor_operation_group.py
index 1d78aeeb83..0dc47fe272 100644
--- a/shopfloor/models/shopfloor_operation_group.py
+++ b/shopfloor/models/shopfloor_operation_group.py
@@ -8,7 +8,5 @@ class ShopfloorOperationGroup(models.Model):
name = fields.Char(required=True)
user_ids = fields.Many2many("res.users", string="Members")
menu_ids = fields.Many2many(
- 'shopfloor.menu',
- string="Menus",
- help="Can see these menus",
+ "shopfloor.menu", string="Menus", help="Can see these menus"
)
diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py
index accbc02155..b1daa88f4d 100644
--- a/shopfloor/models/shopfloor_process.py
+++ b/shopfloor/models/shopfloor_process.py
@@ -7,5 +7,5 @@ class ShopfloorProcess(models.Model):
name = fields.Char(required=True)
picking_type_ids = fields.One2many(
- 'stock.picking.type', 'process_id', string="Operation types"
+ "stock.picking.type", "process_id", string="Operation types"
)
diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py
index 206b68c7fe..808bf50552 100644
--- a/shopfloor/models/stock_picking_type.py
+++ b/shopfloor/models/stock_picking_type.py
@@ -2,6 +2,6 @@
class StockPickingType(models.Model):
- _inherit = 'stock.picking.type'
+ _inherit = "stock.picking.type"
- process_id = fields.Many2one('shopfloor.process', string="Process")
+ process_id = fields.Many2one("shopfloor.process", string="Process")
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index f85f3c3f94..5ae4ec521d 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -1,17 +1,16 @@
from contextlib import contextmanager
+
+from odoo.tests.common import SavepointCase
+
from odoo.addons.base_rest.controllers.main import _PseudoCollection
from odoo.addons.component.core import WorkContext
-from odoo.tests.common import SavepointCase
class CommonCase(SavepointCase):
-
@contextmanager
def work_on_services(self, **params):
params = params or {}
collection = _PseudoCollection("shopfloor.service", self.env)
yield WorkContext(
- model_name="rest.service.registration",
- collection=collection,
- **params
+ model_name="rest.service.registration", collection=collection, **params
)
From 4289176d46e3c3806d6763aee5aff9ff8b23f0b5 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 22 Jan 2020 12:54:45 +0100
Subject: [PATCH 034/940] Use expression.AND for combining domains
---
shopfloor/services/device.py | 20 ++++++++++++++------
shopfloor/services/menu.py | 18 +++++++++++++-----
2 files changed, 27 insertions(+), 11 deletions(-)
diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py
index b2609f4245..f3605592bc 100644
--- a/shopfloor/services/device.py
+++ b/shopfloor/services/device.py
@@ -1,4 +1,5 @@
from odoo import fields
+from odoo.osv import expression
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -20,15 +21,22 @@ def search(self, name_fragment=None):
def _get_base_search_domain(self):
# shopfloor_device_ids is a one2one
+ base_domain = super()._get_base_search_domain()
user = self.env.user
assigned_device = fields.first(user.shopfloor_device_ids)
if assigned_device:
- return [("id", "=", assigned_device.id)]
- return [
- "|",
- ("operation_group_ids", "=", False),
- ("operation_group_ids.user_ids", "=", user.id),
- ]
+ return expression.AND([base_domain, [("id", "=", assigned_device.id)]])
+
+ return expression.AND(
+ [
+ base_domain,
+ [
+ "|",
+ ("operation_group_ids", "=", False),
+ ("operation_group_ids.user_ids", "=", user.id),
+ ],
+ ]
+ )
def _validator_search(self):
return {
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 3fdf5182a0..8f8b863140 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -1,3 +1,5 @@
+from odoo.osv import expression
+
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -9,12 +11,18 @@ class ShopfloorMenu(Component):
_expose_model = "shopfloor.menu"
def _get_base_search_domain(self):
+ base_domain = super()._get_base_search_domain()
user = self.env.user
- return [
- "|",
- ("operation_group_ids", "=", False),
- ("operation_group_ids.user_ids", "=", user.id),
- ]
+ return expression.AND(
+ [
+ base_domain,
+ [
+ "|",
+ ("operation_group_ids", "=", False),
+ ("operation_group_ids.user_ids", "=", user.id),
+ ],
+ ]
+ )
def search(self, name_fragment=None):
"""List available menu entries for current user"""
From 6d25facf50ce2d207cefd698a0f70284ac2b2227 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 22 Jan 2020 12:55:13 +0100
Subject: [PATCH 035/940] Add listing of locations
---
shopfloor/services/__init__.py | 1 +
shopfloor/services/location.py | 70 ++++++++++++++++++++++++++++++++++
2 files changed, 71 insertions(+)
create mode 100644 shopfloor/services/location.py
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 1fa6f94a43..7a379146da 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -2,3 +2,4 @@
from . import device
from . import menu
from . import pack
+from . import location
diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py
new file mode 100644
index 0000000000..2ed25b05a1
--- /dev/null
+++ b/shopfloor/services/location.py
@@ -0,0 +1,70 @@
+from odoo.osv import expression
+
+from odoo.addons.base_rest.components.service import to_int
+from odoo.addons.component.core import Component
+
+
+class ShopfloorLocation(Component):
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.location"
+ _usage = "location"
+ _expose_model = "stock.location"
+
+ def search(self, name_fragment=None):
+ """List available devices for current user"""
+ domain = self._get_base_search_domain()
+ if name_fragment:
+ domain = expression.AND(
+ [
+ domain,
+ [
+ "|",
+ ("name", "ilike", name_fragment),
+ ("barcode", "ilike", name_fragment),
+ ],
+ ]
+ )
+ records = self.env[self._expose_model].search(domain)
+ return {"size": len(records), "data": self._to_json(records)}
+
+ def _get_base_search_domain(self):
+ # TODO add filter on warehouse of the device
+ return super()._get_base_search_domain()
+
+ def _validator_search(self):
+ return {
+ "name_fragment": {"type": "string", "nullable": True, "required": False}
+ }
+
+ def _validator_return_search(self):
+ return {
+ "size": {"coerce": to_int, "required": True, "type": "integer"},
+ "data": {
+ "type": "list",
+ "schema": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "complete_name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ "barcode": {
+ "type": "string",
+ "nullable": False,
+ "required": False,
+ },
+ },
+ },
+ },
+ }
+
+ def _convert_one_record(self, record):
+ return {
+ "id": record.id,
+ "name": record.name,
+ "complete_name": record.complete_name,
+ "barcode": record.barcode or "",
+ }
From b2d3a67aad1180d59fb47c48e2e98c5968c08466 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 22 Jan 2020 13:20:09 +0100
Subject: [PATCH 036/940] Rename 'device' to 'profile'
We don't want to force having one device record per hardware device,
since adding a new scanner would require to create a new device on Odoo.
Instead, we prefer to have profiles, which has to be selected at loading
of the client application. The profile will hold the configuration for
the interactions (warehouse, but maybe later printer, ...).
The allowed profiles can be restricted to groups. A user can be forced
to use a profile.
---
shopfloor/__manifest__.py | 4 +--
...ce_demo.xml => shopfloor_profile_demo.xml} | 6 ++--
shopfloor/models/__init__.py | 2 +-
shopfloor/models/res_users.py | 4 +--
...opfloor_device.py => shopfloor_profile.py} | 20 +++++-------
shopfloor/security/ir.model.access.csv | 4 +--
shopfloor/services/__init__.py | 2 +-
shopfloor/services/location.py | 4 +--
shopfloor/services/{device.py => profile.py} | 31 +++++++++++++------
shopfloor/views/menus.xml | 2 +-
..._views.xml => shopfloor_profile_views.xml} | 29 +++++++----------
11 files changed, 56 insertions(+), 52 deletions(-)
rename shopfloor/demo/{shopfloor_device_demo.xml => shopfloor_profile_demo.xml} (69%)
rename shopfloor/models/{shopfloor_device.py => shopfloor_profile.py} (56%)
rename shopfloor/services/{device.py => profile.py} (75%)
rename shopfloor/views/{shopfloor_device_views.xml => shopfloor_profile_views.xml} (59%)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index b4043333aa..bfd999f5f0 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -18,7 +18,7 @@
"views/shopfloor_menu.xml",
"views/shopfloor_process.xml",
"views/stock_picking_type.xml",
- "views/shopfloor_device_views.xml",
+ "views/shopfloor_profile_views.xml",
"views/menus.xml",
],
"demo": [
@@ -26,6 +26,6 @@
"demo/shopfloor_process_demo.xml",
"demo/shopfloor_menu_demo.xml",
"demo/shopfloor_operation_group_demo.xml",
- "demo/shopfloor_device_demo.xml",
+ "demo/shopfloor_profile_demo.xml",
],
}
diff --git a/shopfloor/demo/shopfloor_device_demo.xml b/shopfloor/demo/shopfloor_profile_demo.xml
similarity index 69%
rename from shopfloor/demo/shopfloor_device_demo.xml
rename to shopfloor/demo/shopfloor_profile_demo.xml
index bd9ed3f250..918ad669e2 100644
--- a/shopfloor/demo/shopfloor_device_demo.xml
+++ b/shopfloor/demo/shopfloor_profile_demo.xml
@@ -1,18 +1,18 @@
-
+
Highbay Truck 1
-
+
Highbay Truck 2
-
+
Shelf 1
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index 6878290486..1aa3c9fbc8 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -2,5 +2,5 @@
from . import shopfloor_operation_group
from . import shopfloor_process
from . import stock_picking_type
-from . import shopfloor_device
+from . import shopfloor_profile
from . import res_users
diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py
index 6838b582ee..8c136f725e 100644
--- a/shopfloor/models/res_users.py
+++ b/shopfloor/models/res_users.py
@@ -5,6 +5,6 @@ class ResUsers(models.Model):
_inherit = "res.users"
# in practice, it's a one2one
- shopfloor_device_ids = fields.One2many(
- comodel_name="shopfloor.device", inverse_name="user_id", readonly=True
+ shopfloor_profile_ids = fields.One2many(
+ comodel_name="shopfloor.profile", inverse_name="user_id", readonly=True
)
diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_profile.py
similarity index 56%
rename from shopfloor/models/shopfloor_device.py
rename to shopfloor/models/shopfloor_profile.py
index 25acbe2dc6..f18d813b31 100644
--- a/shopfloor/models/shopfloor_device.py
+++ b/shopfloor/models/shopfloor_profile.py
@@ -1,9 +1,9 @@
from odoo import api, fields, models
-class ShopfloorDevice(models.Model):
- _name = "shopfloor.device"
- _description = "Shopfloor device settings"
+class ShopfloorProfile(models.Model):
+ _name = "shopfloor.profile"
+ _description = "Shopfloor profile settings"
name = fields.Char(required=True)
warehouse_id = fields.Many2one(
@@ -14,25 +14,21 @@ class ShopfloorDevice(models.Model):
operation_group_ids = fields.Many2many(
"shopfloor.operation.group",
string="Shopfloor Operation Groups",
- help="When unset, all users can use the device. When set,"
- "only users belonging to at least one group can use the device.",
+ help="When unset, all users can use the profile. When set,"
+ "only users belonging to at least one group can use the profile.",
)
user_id = fields.Many2one(
"res.users",
copy=False,
- help="Optional user using the device. The device will"
- "use this configuration when the users logs in the client "
- "application.",
+ help="Optional user using the profile. When a profile has a"
+ "user assigned to it, the user is not allowed to use another profile.",
)
- shopfloor_current_process = fields.Char(readonly=True)
- shopfloor_last_call = fields.Char(readonly=True)
- shopfloor_picking_id = fields.Many2one("stock.picking", readonly=True)
_sql_constraints = [
(
"user_id_uniq",
"unique(user_id)",
- "A user can be assigned to only one device.",
+ "A user can be assigned to only one profile.",
)
]
diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv
index 6db6c86799..a616c579ee 100644
--- a/shopfloor/security/ir.model.access.csv
+++ b/shopfloor/security/ir.model.access.csv
@@ -3,6 +3,6 @@
"access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager",1,1,1,1
"access_shopfloor_operation_group_users","shopfloor operation group","model_shopfloor_operation_group",,1,0,0,0
"access_shopfloor_operation_group_stock_manager","shopfloor operation group inventory manager","model_shopfloor_operation_group","stock.group_stock_manager",1,1,1,1
-"access_shopfloor_device_users","shopfloor device","model_shopfloor_device",,1,0,0,0
-"access_shopfloor_device_stock_manager","shopfloor device inventory manager","model_shopfloor_device","stock.group_stock_manager",1,1,1,1
+"access_shopfloor_profile_users","shopfloor profile","model_shopfloor_profile",,1,0,0,0
+"access_shopfloor_profile_stock_manager","shopfloor profile inventory manager","model_shopfloor_profile","stock.group_stock_manager",1,1,1,1
"access_shopfloor_process_users","shopfloor process","model_shopfloor_process",,1,0,0,0
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 7a379146da..f72a999243 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -1,5 +1,5 @@
from . import service
-from . import device
+from . import profile
from . import menu
from . import pack
from . import location
diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py
index 2ed25b05a1..8f65a1febb 100644
--- a/shopfloor/services/location.py
+++ b/shopfloor/services/location.py
@@ -11,7 +11,7 @@ class ShopfloorLocation(Component):
_expose_model = "stock.location"
def search(self, name_fragment=None):
- """List available devices for current user"""
+ """List available locations for current user"""
domain = self._get_base_search_domain()
if name_fragment:
domain = expression.AND(
@@ -28,7 +28,7 @@ def search(self, name_fragment=None):
return {"size": len(records), "data": self._to_json(records)}
def _get_base_search_domain(self):
- # TODO add filter on warehouse of the device
+ # TODO add filter on warehouse of the current profile
return super()._get_base_search_domain()
def _validator_search(self):
diff --git a/shopfloor/services/device.py b/shopfloor/services/profile.py
similarity index 75%
rename from shopfloor/services/device.py
rename to shopfloor/services/profile.py
index f3605592bc..001a31a52e 100644
--- a/shopfloor/services/device.py
+++ b/shopfloor/services/profile.py
@@ -5,14 +5,27 @@
from odoo.addons.component.core import Component
-class ShopfloorDevice(Component):
+class ShopfloorProfile(Component):
+ """Profile storing the configuration for the interaction from the client.
+
+ A client application must use a profile, passed to every request in the
+ HTTP header (TODO put the name of the header).
+
+ The list of profiles available for a user is restricted by 2 things:
+
+ * If the profile has operation groups, the profile can be used only
+ if the user is at least in one of these groups.
+ * If the user has an assigned profile, the user can use only this profile.
+
+ """
+
_inherit = "base.shopfloor.service"
- _name = "shopfloor.device"
- _usage = "device"
- _expose_model = "shopfloor.device"
+ _name = "shopfloor.profile"
+ _usage = "profile"
+ _expose_model = "shopfloor.profile"
def search(self, name_fragment=None):
- """List available devices for current user"""
+ """List available profiles for current user"""
domain = self._get_base_search_domain()
if name_fragment:
domain.append(("name", "ilike", name_fragment))
@@ -20,12 +33,12 @@ def search(self, name_fragment=None):
return {"size": len(records), "data": self._to_json(records)}
def _get_base_search_domain(self):
- # shopfloor_device_ids is a one2one
+ # shopfloor_profile_ids is a one2one in practice.
base_domain = super()._get_base_search_domain()
user = self.env.user
- assigned_device = fields.first(user.shopfloor_device_ids)
- if assigned_device:
- return expression.AND([base_domain, [("id", "=", assigned_device.id)]])
+ assigned_profile = fields.first(user.shopfloor_profile_ids)
+ if assigned_profile:
+ return expression.AND([base_domain, [("id", "=", assigned_profile.id)]])
return expression.AND(
[
diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml
index f6bc4f105d..71f19f9f78 100644
--- a/shopfloor/views/menus.xml
+++ b/shopfloor/views/menus.xml
@@ -4,5 +4,5 @@
-
+
diff --git a/shopfloor/views/shopfloor_device_views.xml b/shopfloor/views/shopfloor_profile_views.xml
similarity index 59%
rename from shopfloor/views/shopfloor_device_views.xml
rename to shopfloor/views/shopfloor_profile_views.xml
index 1f6dc4cc25..26720fae2b 100644
--- a/shopfloor/views/shopfloor_device_views.xml
+++ b/shopfloor/views/shopfloor_profile_views.xml
@@ -1,8 +1,8 @@
-
- shopfloor.device tree
- shopfloor.device
+
+ shopfloor.profile tree
+ shopfloor.profile
@@ -13,9 +13,9 @@
-
- shopfloor.device form
- shopfloor.device
+
+ shopfloor.profile form
+ shopfloor.profile
@@ -28,20 +28,15 @@
-
-
-
-
-
-
- shopfloor.device search
- shopfloor.device
+
+ shopfloor.profile search
+ shopfloor.profile
@@ -52,9 +47,9 @@
-
- Devices
- shopfloor.device
+
+ Profiles
+ shopfloor.profile
ir.actions.act_window
tree,form
From 8092992eda8f65f2bfc0c2820b842ecb42ac9ecd Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 22 Jan 2020 13:41:36 +0100
Subject: [PATCH 037/940] Fix initialization of tests
Components have to be initalized (when running pytest-odoo, we don't
need it, but to run odoo tests, we have to use the ComponentMixin
to initialize the system).
---
shopfloor/tests/common.py | 24 +++++++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 5ae4ec521d..1f14f5ac93 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -4,9 +4,14 @@
from odoo.addons.base_rest.controllers.main import _PseudoCollection
from odoo.addons.component.core import WorkContext
+from odoo.addons.component.tests.common import ComponentMixin
-class CommonCase(SavepointCase):
+class CommonCase(SavepointCase, ComponentMixin):
+
+ # by default disable tracking suite-wise, it's a time saver :)
+ tracking_disable = True
+
@contextmanager
def work_on_services(self, **params):
params = params or {}
@@ -14,3 +19,20 @@ def work_on_services(self, **params):
yield WorkContext(
model_name="rest.service.registration", collection=collection, **params
)
+
+ # pylint: disable=method-required-super
+ # super is called "the old-style way" to call both super classes in the
+ # order we want
+ def setUp(self):
+ # Have to initialize both odoo env and stuff +
+ # the Component registry of the mixin
+ SavepointCase.setUp(self)
+ ComponentMixin.setUp(self)
+
+ @classmethod
+ def setUpClass(cls):
+ super(CommonCase, cls).setUpClass()
+ cls.env = cls.env(
+ context=dict(cls.env.context, tracking_disable=cls.tracking_disable)
+ )
+ cls.setUpComponent()
From 98efc9342340d05f3f639b528900a0c1cf558a92 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 22 Jan 2020 15:48:07 +0100
Subject: [PATCH 038/940] Add description on services (displayed on swagger)
---
shopfloor/services/location.py | 3 +++
shopfloor/services/menu.py | 9 +++++++++
shopfloor/services/pack.py | 3 +++
shopfloor/services/profile.py | 5 +++--
shopfloor/services/service.py | 2 ++
5 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py
index 8f65a1febb..17aac7be05 100644
--- a/shopfloor/services/location.py
+++ b/shopfloor/services/location.py
@@ -5,10 +5,13 @@
class ShopfloorLocation(Component):
+ """Expose Stock Locations data for the current warehouse."""
+
_inherit = "base.shopfloor.service"
_name = "shopfloor.location"
_usage = "location"
_expose_model = "stock.location"
+ _description = __doc__
def search(self, name_fragment=None):
"""List available locations for current user"""
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 8f8b863140..e9d7fbfc50 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -5,10 +5,19 @@
class ShopfloorMenu(Component):
+ """
+ Menu Structure for the client application.
+
+ The list of menus is restricted by the operation groups. A menu without
+ groups is visible for all users, a menu with group(s) is visible if the
+ user is in at least one of the groups.
+ """
+
_inherit = "base.shopfloor.service"
_name = "shopfloor.menu"
_usage = "menu"
_expose_model = "shopfloor.menu"
+ _description = __doc__
def _get_base_search_domain(self):
base_domain = super()._get_base_search_domain()
diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py
index bf098599a3..32f7a1b20f 100644
--- a/shopfloor/services/pack.py
+++ b/shopfloor/services/pack.py
@@ -3,9 +3,12 @@
class ShopfloorPack(Component):
+ """Expose data about Stock Quant Packages"""
+
_inherit = "base.shopfloor.service"
_name = "shopfloor.pack"
_usage = "pack"
+ _description = __doc__
# TODO define the return schema and add the validator method
@skip_secure_response
diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py
index 001a31a52e..92f9d3eac3 100644
--- a/shopfloor/services/profile.py
+++ b/shopfloor/services/profile.py
@@ -6,7 +6,8 @@
class ShopfloorProfile(Component):
- """Profile storing the configuration for the interaction from the client.
+ """
+ Profile storing the configuration for the interaction from the client.
A client application must use a profile, passed to every request in the
HTTP header (TODO put the name of the header).
@@ -16,13 +17,13 @@ class ShopfloorProfile(Component):
* If the profile has operation groups, the profile can be used only
if the user is at least in one of these groups.
* If the user has an assigned profile, the user can use only this profile.
-
"""
_inherit = "base.shopfloor.service"
_name = "shopfloor.profile"
_usage = "profile"
_expose_model = "shopfloor.profile"
+ _description = __doc__
def search(self, name_fragment=None):
"""List available profiles for current user"""
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index d19ba3db27..d48cfe3f78 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -6,6 +6,8 @@
class BaseShopfloorService(AbstractComponent):
+ """Base class for REST services"""
+
_inherit = "base.rest.service"
_name = "base.shopfloor.service"
_collection = "shopfloor.service"
From 03b31209b2d2e3b411adbf269fde8ef69d58db78 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 22 Jan 2020 16:50:27 +0100
Subject: [PATCH 039/940] Add a code for the process
The code will be used for:
* Customize the view depending of the code (for instance, we could have
a field "allow to replace a lot" on a "Single Pack Transfer" process, and
a second "Single Pack Transfer" without allowing this)
* the code is the identifier of the method in the REST API
(/shopfloor//)
---
shopfloor/demo/shopfloor_menu_demo.xml | 6 ++++++
shopfloor/demo/shopfloor_process_demo.xml | 6 ++++++
shopfloor/demo/shopfloor_profile_demo.xml | 10 ++--------
shopfloor/models/shopfloor_menu.py | 3 ++-
shopfloor/models/shopfloor_process.py | 8 ++++++++
shopfloor/services/__init__.py | 2 ++
shopfloor/services/menu.py | 7 ++++++-
shopfloor/services/single_pack_putaway.py | 10 ++++++++++
shopfloor/services/single_pack_transfer.py | 10 ++++++++++
shopfloor/views/shopfloor_process.xml | 3 +++
10 files changed, 55 insertions(+), 10 deletions(-)
create mode 100644 shopfloor/services/single_pack_putaway.py
create mode 100644 shopfloor/services/single_pack_transfer.py
diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml
index 7d643a0e1f..6d0d511f79 100644
--- a/shopfloor/demo/shopfloor_menu_demo.xml
+++ b/shopfloor/demo/shopfloor_menu_demo.xml
@@ -6,4 +6,10 @@
+
+
diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml
index 30964d7b23..3f869cb457 100644
--- a/shopfloor/demo/shopfloor_process_demo.xml
+++ b/shopfloor/demo/shopfloor_process_demo.xml
@@ -2,6 +2,12 @@
Put-Away Reach Truck
+ single_pack_putaway
+
+
+
+ Single Pallet Transfer
+ single_pack_transfer
diff --git a/shopfloor/demo/shopfloor_profile_demo.xml b/shopfloor/demo/shopfloor_profile_demo.xml
index 918ad669e2..5ef9df4179 100644
--- a/shopfloor/demo/shopfloor_profile_demo.xml
+++ b/shopfloor/demo/shopfloor_profile_demo.xml
@@ -1,13 +1,7 @@
-
- Highbay Truck 1
-
-
-
-
-
- Highbay Truck 2
+
+ Highbay Truck
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index f95d37c382..0f0a56e2bb 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -11,4 +11,5 @@ class ShopfloorMenu(models.Model):
operation_group_ids = fields.Many2many(
"shopfloor.operation.group", string="Groups", help="visible for these groups"
)
- process_id = fields.Many2one("shopfloor.process", name="Process")
+ process_id = fields.Many2one("shopfloor.process", name="Process", required=True)
+ process_code = fields.Selection(related="process_id.code", readonly=True)
diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py
index b1daa88f4d..db4759c2d0 100644
--- a/shopfloor/models/shopfloor_process.py
+++ b/shopfloor/models/shopfloor_process.py
@@ -6,6 +6,14 @@ class ShopfloorProcess(models.Model):
_description = "a process to be run from the scanners"
name = fields.Char(required=True)
+ code = fields.Selection(selection="_selection_code", required=True)
picking_type_ids = fields.One2many(
"stock.picking.type", "process_id", string="Operation types"
)
+
+ def _selection_code(self):
+ return [
+ # these must match a REST service
+ ("single_pack_putaway", "Single Pack Put-away"),
+ ("single_pack_transfer", "Single Pack Transfer"),
+ ]
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index f72a999243..bb82a1df2e 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -3,3 +3,5 @@
from . import menu
from . import pack
from . import location
+from . import single_pack_putaway
+from . import single_pack_transfer
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index e9d7fbfc50..220003a559 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -56,10 +56,15 @@ def _validator_return_search(self):
"schema": {
"id": {"coerce": to_int, "required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
+ "process": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
},
},
},
}
def _convert_one_record(self, record):
- return {"id": record.id, "name": record.name}
+ return {"id": record.id, "name": record.name, "process": record.process_code}
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
new file mode 100644
index 0000000000..f46024bd0d
--- /dev/null
+++ b/shopfloor/services/single_pack_putaway.py
@@ -0,0 +1,10 @@
+from odoo.addons.component.core import Component
+
+
+class SinglePackPutaway(Component):
+ """Methods for the Single Pack Put-Away Process"""
+
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.single.pack.putaway"
+ _usage = "single_pack_putaway"
+ _description = __doc__
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
new file mode 100644
index 0000000000..a22659a1cb
--- /dev/null
+++ b/shopfloor/services/single_pack_transfer.py
@@ -0,0 +1,10 @@
+from odoo.addons.component.core import Component
+
+
+class SinglePackTransfer(Component):
+ """Methods for the Single Pack Transfer Process"""
+
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.single.pack.transfer"
+ _usage = "single_pack_transfer"
+ _description = __doc__
diff --git a/shopfloor/views/shopfloor_process.xml b/shopfloor/views/shopfloor_process.xml
index 67afa35bc8..db58ed0b34 100644
--- a/shopfloor/views/shopfloor_process.xml
+++ b/shopfloor/views/shopfloor_process.xml
@@ -6,6 +6,7 @@
+
@@ -20,6 +21,7 @@
+
@@ -36,6 +38,7 @@
+
From 75734d3089c1512bc9572b0ff1f16ac62aae90bf Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 22 Jan 2020 17:27:15 +0100
Subject: [PATCH 040/940] Rework HTTP Header parameters
* We need the menu id and the profile id, from there, we can find the
configuration for the processes
* Get recordsets in the work context, so we can conveniently use them
from service components (and no actual SELECT is issued until we use
them)
* Return 400 BadRequest if a parameter is missing, but do not check
existence of the record to avoid queries before we actually need the
records, considering we won't need them for every request (a method
toinspect a pack for instance does not care about the menu or the
profile)
---
shopfloor/controllers/main.py | 23 +++++++++++++++++------
shopfloor/services/service.py | 16 ++++++++--------
2 files changed, 25 insertions(+), 14 deletions(-)
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index 34be9aae06..297b0e4033 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -1,3 +1,5 @@
+from werkzeug.exceptions import BadRequest
+
from odoo.http import request
from odoo.addons.base_rest.controllers import main
@@ -11,13 +13,22 @@ class ShopfloorController(main.RestController):
def _get_component_context(self):
"""
This method adds the component context:
- * the process name
- * the process menu
+ * the shopfloor menu in ``self.work.menu`` from the service Components
+ * the shopfloor profile in ``self.work.profile`` from the service
+ Components
"""
res = super(ShopfloorController, self)._get_component_context()
headers = request.httprequest.environ
- for k, v in headers.items():
- if k.startswith("HTTP_SERVICE_CTX_"):
- key_name = k[17:].lower()
- res[key_name] = v
+ try:
+ menu_id = int(headers.get("HTTP_SERVICE_CTX_MENU_ID"))
+ except (TypeError, ValueError):
+ raise BadRequest("HTTP_SERVICE_CTX_MENU_ID must be set with an integer")
+ res["menu"] = request.env["shopfloor.menu"].browse(menu_id)
+
+ try:
+ profile_id = int(headers.get("HTTP_SERVICE_CTX_PROFILE_ID"))
+ except (TypeError, ValueError):
+ raise BadRequest("HTTP_SERVICE_CTX_PROFILE_ID must be set with an integer")
+ res["profile"] = request.env["shopfloor.profile"].browse(profile_id)
+
return res
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index d48cfe3f78..2363b60af4 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -54,22 +54,22 @@ def _get_openapi_default_parameters(self):
"value": demo_api_key,
},
{
- "name": "SERVICE_CTX_PROCESS_NAME",
+ "name": "SERVICE_CTX_MENU_ID",
"in": "header",
- "description": "Name of the current process",
+ "description": "ID of the current menu",
"required": True,
- "schema": {"type": "string"},
+ "schema": {"type": "integer"},
"style": "simple",
- "value": "Put-Away Reach Truck",
+ "value": "1",
},
{
- "name": "SERVICE_CTX_PROCESS_MENU",
+ "name": "SERVICE_CTX_PROFILE_ID",
"in": "header",
- "description": "Name of the current process menu",
+ "description": "ID of the current profile",
"required": True,
- "schema": {"type": "string"},
+ "schema": {"type": "integer"},
"style": "simple",
- "value": "Put-Away Reach Truck",
+ "value": "1",
},
]
)
From ef8ded0d1d7b3445014d368f1c3d186452d74be4 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Thu, 23 Jan 2020 10:04:30 +0100
Subject: [PATCH 041/940] refactor of pack scan service need to be improved
again
---
shopfloor/services/pack.py | 257 ++++++++++++++++++++++++++++----
shopfloor/tests/test_putaway.py | 17 ++-
2 files changed, 242 insertions(+), 32 deletions(-)
diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py
index 32f7a1b20f..9cca11de74 100644
--- a/shopfloor/services/pack.py
+++ b/shopfloor/services/pack.py
@@ -12,15 +12,88 @@ class ShopfloorPack(Component):
# TODO define the return schema and add the validator method
@skip_secure_response
- def scan(self, pack_name):
+ def scan(self, barcode):
"""Scan a pack barcode"""
- pack = self.env["stock.quant.package"].search([("name", "=", pack_name)])
+
company = self.env.user.company_id # FIXME add logic to get proper company
# FIXME add logic to get proper warehouse
warehouse = self.env["stock.warehouse"].search([])[0]
picking_type = (
warehouse.int_type_id
) # FIXME add logic to get picking type properly
+
+ # TODO define on what we search (pack name, pack barcode ...)
+ pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
+ if not pack:
+ return {
+ "success": False,
+ "code": "not_found",
+ "message": {
+ "title": "Pack not found",
+ "body": "the pack %s doesn't exists" % barcode,
+ },
+ }
+ allowed_locations = self.env["stock.location"].search(
+ [("id", "child_of", picking_type.default_location_src_id.id)]
+ )
+ if pack.location_id not in allowed_locations:
+ return {
+ "success": False,
+ "code": "forbidden",
+ "message": {
+ "title": "do not process",
+ "body": "pack %s is not in %s location"
+ % (barcode, picking_type.default_location_src_id.name),
+ },
+ }
+ quantity = pack.quant_ids[0].quantity
+ existing_operations = self.env["stock.move.line"].search(
+ [("qty_done", "=", quantity), ("package_id", "=", pack.id)]
+ )
+ if (
+ existing_operations
+ and existing_operations[0].picking_id.picking_type_id != picking_type
+ ):
+ return {
+ "success": False,
+ "code": "forbidden",
+ "message": {
+ "title": "do not process",
+ "body": "An operation exists in %s %s. "
+ "You cannot process it with this shopfloor process."
+ % (
+ existing_operations[0].picking_id.picking_type_id.name,
+ existing_operations[0].picking_id.name,
+ ),
+ },
+ }
+ elif existing_operations:
+ move = existing_operations.move_id
+ return {
+ "data": {
+ "id": move.move_line_ids[0].package_level_id.id,
+ "location_src": {
+ "id": pack.location_id.id,
+ "name": pack.location_id.name,
+ },
+ "location_dst": {
+ "id": move.move_line_ids[0].location_dest_id.id,
+ "name": move.move_line_ids[0].location_dest_id.name,
+ },
+ "product": {
+ "id": move.product_id.name,
+ "name": move.product_id.name,
+ },
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ },
+ "success": False,
+ "code": "need_confirmation",
+ "message": {
+ "title": "Already started",
+ "body": "Operation already running. "
+ "Would you like to take it over ?",
+ },
+ }
product = pack.quant_ids[
0
].product_id # FIXME we consider only one product per pack
@@ -45,45 +118,179 @@ def scan(self, pack_name):
)
move.picking_id.action_assign()
return_vals = {
- "name": pack.name,
- "location_name": pack.location_id.name,
- "location_dest_name": move.move_line_ids[0].location_dest_id.name,
- "product_name": move.name,
- "picking_name": move.picking_id.name,
- "location_id": pack.location_id.id,
- "location_dest_id": move.move_line_ids[0].location_dest_id.id,
- "move_id": move.id,
- # 'allow_change_destination': True, #TODO
+ "success": True,
+ "data": {
+ "id": move.move_line_ids[0].package_level_id.id,
+ "location_src": {
+ "id": pack.location_id.id,
+ "name": pack.location_id.name,
+ },
+ "location_dst": {
+ "id": move.move_line_ids[0].location_dest_id.id,
+ "name": move.move_line_ids[0].location_dest_id.name,
+ },
+ "product": {"id": move.product_id.name, "name": move.product_id.name},
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ },
}
return return_vals
- def validate(self, move_id, location_name):
- move = self.env["stock.move"].browse(move_id)
+ def validate(self, package_level_id, location_name, confirmation=False):
+ package = self.env["stock.package_level"].browse(package_level_id)
+ move = package.move_line_ids[0].move_id
dest_location = self.env["stock.location"].search(
[("name", "=", location_name)]
)
- if move.move_line_ids[0].location_dest_id.id != dest_location.id:
- move.move_line_ids[0].location_dest_id = dest_location.id
- move.move_line_ids[0].qty_done = move.move_line_ids[0].product_uom_qty
- move.picking_id.button_validate()
- return True
+ move_dest_location = move.move_line_ids[0].location_dest_id
+ allowed_locations = self.env["stock.location"].search(
+ [
+ (
+ "id",
+ "child_of",
+ move.picking_id.picking_type_id.default_location_dest_id.id,
+ )
+ ]
+ )
+ zone_locations = self.env["stock.location"].search(
+ [("id", "child_of", move_dest_location.id)]
+ )
+ if dest_location not in allowed_locations:
+ return {
+ "success": False,
+ "code": "forbidden",
+ "message": {"title": "Forbidden", "body": "You cannot place it here"},
+ }
+ elif (
+ dest_location in allowed_locations
+ and dest_location not in zone_locations
+ and confirmation
+ ):
+ return {
+ "success": False,
+ "code": "need_confirmation",
+ "message": {"title": "Confirm", "body": "Are you sure ?"},
+ }
+ if move.state == "cancel":
+ return {
+ "success": False,
+ "code": "restart",
+ "message": {
+ "title": "Restart",
+ "body": "Restart the operation someone has canceld it.",
+ },
+ }
+ move.move_line_ids[0].location_dest_id = dest_location.id
+ move._action_done()
+ return {"success": True}
- def cancel(self, move_id):
- move = self.env["stock.move"].browse(move_id)
- move.picking_id.cancel()
- return True
+ def cancel(self, package_level_id):
+ package = self.env["stock.package_level"].browse(package_level_id)
+ package.move_ids[0].cancel()
+ return {"success": True}
def _validator_cancel(self):
- return {"move_id": {"coerce": to_int, "required": True, "type": "integer"}}
+ return {
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}
+ }
def _validator_validate(self):
return {
- "move_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
"location_name": {"type": "string", "nullable": False, "required": True},
}
+ def _validator_return_validate(self):
+ return {
+ "success": {"type": "boolean", "nullable": True, "required": True},
+ "code": {"type": "string", "nullable": True, "required": False},
+ "message": {
+ "type": "dict",
+ "schema": {
+ "body": {"type": "string", "nullable": False, "required": True}
+ },
+ },
+ }
+
def _validator_scan(self):
- return {"pack_name": {"type": "string", "nullable": False, "required": True}}
+ return {"barcode": {"type": "string", "nullable": False, "required": True}}
+
+ def _validator_return_scan(self):
+ return {
+ "success": {"type": "boolean", "nullable": True, "required": True},
+ "code": {"type": "string", "nullable": True, "required": False},
+ "message": {
+ "type": "dict",
+ "schema": {
+ "body": {"type": "string", "nullable": False, "required": True}
+ },
+ },
+ "data": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "location_src": {
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ "location_dst": {
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ "product": {
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ },
+ },
+ }
def get_by_name(self, pack_name):
"""
diff --git a/shopfloor/tests/test_putaway.py b/shopfloor/tests/test_putaway.py
index 53c325cb0c..5b09c33241 100644
--- a/shopfloor/tests/test_putaway.py
+++ b/shopfloor/tests/test_putaway.py
@@ -4,18 +4,17 @@
class PutawayCase(CommonCase):
def setUp(self, *args, **kwargs):
super(PutawayCase, self).setUp(*args, **kwargs)
- in_location = self.env.ref("stock.stock_location_company").child_ids[0]
stock_location = self.env.ref("stock.stock_location_stock")
self.productA = self.env["product.product"].create(
{"name": "Product A", "type": "product"}
)
self.packA = self.env["stock.quant.package"].create(
- {"location_id": in_location.id}
+ {"location_id": stock_location.id}
)
self.quantA = self.env["stock.quant"].create(
{
"product_id": self.productA.id,
- "location_id": in_location.id,
+ "location_id": stock_location.id,
"quantity": 1,
"package_id": self.packA.id,
}
@@ -32,11 +31,15 @@ def setUp(self, *args, **kwargs):
self.service = work.component(usage="pack")
def test_scan_pack(self):
- pack_name = self.packA.name
- params = {"pack_name": pack_name}
+ barcode = self.packA.name
+ params = {"barcode": barcode}
response = self.service.dispatch("scan", params=params)
- move_id = response["move_id"]
- params = {"move_id": move_id, "location_name": response["location_dest_name"]}
+ package_level = self.env["stock.package_level"].browse(response["data"]["id"])
+ move_id = package_level.move_line_ids[0].move_id.id
+ params = {
+ "package_level_id": package_level.id,
+ "location_name": response["data"]["location_dst"]["name"],
+ }
location_dest_id = (
self.env["stock.location"]
.search([("name", "=", params["location_name"])])
From d6356ca7db19dd2804534239463a6a9577aebec6 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Thu, 23 Jan 2020 10:13:44 +0100
Subject: [PATCH 042/940] move single pack putaway service
---
shopfloor/services/pack.py | 284 +-----------------
shopfloor/services/single_pack_putaway.py | 281 +++++++++++++++++
...putaway.py => test_single_pack_putaway.py} | 0
3 files changed, 282 insertions(+), 283 deletions(-)
rename shopfloor/tests/{test_putaway.py => test_single_pack_putaway.py} (100%)
diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py
index 9cca11de74..46b7014764 100644
--- a/shopfloor/services/pack.py
+++ b/shopfloor/services/pack.py
@@ -1,4 +1,4 @@
-from odoo.addons.base_rest.components.service import skip_secure_response, to_int
+from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -10,288 +10,6 @@ class ShopfloorPack(Component):
_usage = "pack"
_description = __doc__
- # TODO define the return schema and add the validator method
- @skip_secure_response
- def scan(self, barcode):
- """Scan a pack barcode"""
-
- company = self.env.user.company_id # FIXME add logic to get proper company
- # FIXME add logic to get proper warehouse
- warehouse = self.env["stock.warehouse"].search([])[0]
- picking_type = (
- warehouse.int_type_id
- ) # FIXME add logic to get picking type properly
-
- # TODO define on what we search (pack name, pack barcode ...)
- pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
- if not pack:
- return {
- "success": False,
- "code": "not_found",
- "message": {
- "title": "Pack not found",
- "body": "the pack %s doesn't exists" % barcode,
- },
- }
- allowed_locations = self.env["stock.location"].search(
- [("id", "child_of", picking_type.default_location_src_id.id)]
- )
- if pack.location_id not in allowed_locations:
- return {
- "success": False,
- "code": "forbidden",
- "message": {
- "title": "do not process",
- "body": "pack %s is not in %s location"
- % (barcode, picking_type.default_location_src_id.name),
- },
- }
- quantity = pack.quant_ids[0].quantity
- existing_operations = self.env["stock.move.line"].search(
- [("qty_done", "=", quantity), ("package_id", "=", pack.id)]
- )
- if (
- existing_operations
- and existing_operations[0].picking_id.picking_type_id != picking_type
- ):
- return {
- "success": False,
- "code": "forbidden",
- "message": {
- "title": "do not process",
- "body": "An operation exists in %s %s. "
- "You cannot process it with this shopfloor process."
- % (
- existing_operations[0].picking_id.picking_type_id.name,
- existing_operations[0].picking_id.name,
- ),
- },
- }
- elif existing_operations:
- move = existing_operations.move_id
- return {
- "data": {
- "id": move.move_line_ids[0].package_level_id.id,
- "location_src": {
- "id": pack.location_id.id,
- "name": pack.location_id.name,
- },
- "location_dst": {
- "id": move.move_line_ids[0].location_dest_id.id,
- "name": move.move_line_ids[0].location_dest_id.name,
- },
- "product": {
- "id": move.product_id.name,
- "name": move.product_id.name,
- },
- "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
- },
- "success": False,
- "code": "need_confirmation",
- "message": {
- "title": "Already started",
- "body": "Operation already running. "
- "Would you like to take it over ?",
- },
- }
- product = pack.quant_ids[
- 0
- ].product_id # FIXME we consider only one product per pack
- move_vals = {
- "picking_type_id": picking_type.id,
- "product_id": product.id,
- "location_id": pack.location_id.id,
- "location_dest_id": picking_type.default_location_dest_id.id,
- "name": product.name,
- "product_uom": product.uom_id.id,
- "product_uom_qty": pack.quant_ids[0].quantity,
- "company_id": company.id,
- }
- move = self.env["stock.move"].create(move_vals)
- move._action_confirm()
- self.env["stock.package_level"].create(
- {
- "package_id": pack.id,
- "move_ids": [(6, 0, [move.id])],
- "company_id": company.id,
- }
- )
- move.picking_id.action_assign()
- return_vals = {
- "success": True,
- "data": {
- "id": move.move_line_ids[0].package_level_id.id,
- "location_src": {
- "id": pack.location_id.id,
- "name": pack.location_id.name,
- },
- "location_dst": {
- "id": move.move_line_ids[0].location_dest_id.id,
- "name": move.move_line_ids[0].location_dest_id.name,
- },
- "product": {"id": move.product_id.name, "name": move.product_id.name},
- "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
- },
- }
- return return_vals
-
- def validate(self, package_level_id, location_name, confirmation=False):
- package = self.env["stock.package_level"].browse(package_level_id)
- move = package.move_line_ids[0].move_id
- dest_location = self.env["stock.location"].search(
- [("name", "=", location_name)]
- )
- move_dest_location = move.move_line_ids[0].location_dest_id
- allowed_locations = self.env["stock.location"].search(
- [
- (
- "id",
- "child_of",
- move.picking_id.picking_type_id.default_location_dest_id.id,
- )
- ]
- )
- zone_locations = self.env["stock.location"].search(
- [("id", "child_of", move_dest_location.id)]
- )
- if dest_location not in allowed_locations:
- return {
- "success": False,
- "code": "forbidden",
- "message": {"title": "Forbidden", "body": "You cannot place it here"},
- }
- elif (
- dest_location in allowed_locations
- and dest_location not in zone_locations
- and confirmation
- ):
- return {
- "success": False,
- "code": "need_confirmation",
- "message": {"title": "Confirm", "body": "Are you sure ?"},
- }
- if move.state == "cancel":
- return {
- "success": False,
- "code": "restart",
- "message": {
- "title": "Restart",
- "body": "Restart the operation someone has canceld it.",
- },
- }
- move.move_line_ids[0].location_dest_id = dest_location.id
- move._action_done()
- return {"success": True}
-
- def cancel(self, package_level_id):
- package = self.env["stock.package_level"].browse(package_level_id)
- package.move_ids[0].cancel()
- return {"success": True}
-
- def _validator_cancel(self):
- return {
- "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}
- }
-
- def _validator_validate(self):
- return {
- "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
- "location_name": {"type": "string", "nullable": False, "required": True},
- }
-
- def _validator_return_validate(self):
- return {
- "success": {"type": "boolean", "nullable": True, "required": True},
- "code": {"type": "string", "nullable": True, "required": False},
- "message": {
- "type": "dict",
- "schema": {
- "body": {"type": "string", "nullable": False, "required": True}
- },
- },
- }
-
- def _validator_scan(self):
- return {"barcode": {"type": "string", "nullable": False, "required": True}}
-
- def _validator_return_scan(self):
- return {
- "success": {"type": "boolean", "nullable": True, "required": True},
- "code": {"type": "string", "nullable": True, "required": False},
- "message": {
- "type": "dict",
- "schema": {
- "body": {"type": "string", "nullable": False, "required": True}
- },
- },
- "data": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "location_src": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
- },
- "location_dst": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
- },
- "product": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
- },
- "picking": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
- },
- },
- },
- }
-
def get_by_name(self, pack_name):
"""
Get pack informations
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index f46024bd0d..3c68068915 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -1,3 +1,4 @@
+from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -8,3 +9,283 @@ class SinglePackPutaway(Component):
_name = "shopfloor.single.pack.putaway"
_usage = "single_pack_putaway"
_description = __doc__
+
+ def scan(self, barcode):
+ """Scan a pack barcode"""
+
+ company = self.env.user.company_id # FIXME add logic to get proper company
+ # FIXME add logic to get proper warehouse
+ warehouse = self.env["stock.warehouse"].search([])[0]
+ picking_type = (
+ warehouse.int_type_id
+ ) # FIXME add logic to get picking type properly
+
+ # TODO define on what we search (pack name, pack barcode ...)
+ pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
+ if not pack:
+ return {
+ "success": False,
+ "code": "not_found",
+ "message": {
+ "title": "Pack not found",
+ "body": "the pack %s doesn't exists" % barcode,
+ },
+ }
+ allowed_locations = self.env["stock.location"].search(
+ [("id", "child_of", picking_type.default_location_src_id.id)]
+ )
+ if pack.location_id not in allowed_locations:
+ return {
+ "success": False,
+ "code": "forbidden",
+ "message": {
+ "title": "do not process",
+ "body": "pack %s is not in %s location"
+ % (barcode, picking_type.default_location_src_id.name),
+ },
+ }
+ quantity = pack.quant_ids[0].quantity
+ existing_operations = self.env["stock.move.line"].search(
+ [("qty_done", "=", quantity), ("package_id", "=", pack.id)]
+ )
+ if (
+ existing_operations
+ and existing_operations[0].picking_id.picking_type_id != picking_type
+ ):
+ return {
+ "success": False,
+ "code": "forbidden",
+ "message": {
+ "title": "do not process",
+ "body": "An operation exists in %s %s. "
+ "You cannot process it with this shopfloor process."
+ % (
+ existing_operations[0].picking_id.picking_type_id.name,
+ existing_operations[0].picking_id.name,
+ ),
+ },
+ }
+ elif existing_operations:
+ move = existing_operations.move_id
+ return {
+ "data": {
+ "id": move.move_line_ids[0].package_level_id.id,
+ "location_src": {
+ "id": pack.location_id.id,
+ "name": pack.location_id.name,
+ },
+ "location_dst": {
+ "id": move.move_line_ids[0].location_dest_id.id,
+ "name": move.move_line_ids[0].location_dest_id.name,
+ },
+ "product": {
+ "id": move.product_id.name,
+ "name": move.product_id.name,
+ },
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ },
+ "success": False,
+ "code": "need_confirmation",
+ "message": {
+ "title": "Already started",
+ "body": "Operation already running. "
+ "Would you like to take it over ?",
+ },
+ }
+ product = pack.quant_ids[
+ 0
+ ].product_id # FIXME we consider only one product per pack
+ move_vals = {
+ "picking_type_id": picking_type.id,
+ "product_id": product.id,
+ "location_id": pack.location_id.id,
+ "location_dest_id": picking_type.default_location_dest_id.id,
+ "name": product.name,
+ "product_uom": product.uom_id.id,
+ "product_uom_qty": pack.quant_ids[0].quantity,
+ "company_id": company.id,
+ }
+ move = self.env["stock.move"].create(move_vals)
+ move._action_confirm()
+ self.env["stock.package_level"].create(
+ {
+ "package_id": pack.id,
+ "move_ids": [(6, 0, [move.id])],
+ "company_id": company.id,
+ }
+ )
+ move.picking_id.action_assign()
+ return_vals = {
+ "success": True,
+ "data": {
+ "id": move.move_line_ids[0].package_level_id.id,
+ "location_src": {
+ "id": pack.location_id.id,
+ "name": pack.location_id.name,
+ },
+ "location_dst": {
+ "id": move.move_line_ids[0].location_dest_id.id,
+ "name": move.move_line_ids[0].location_dest_id.name,
+ },
+ "product": {"id": move.product_id.name, "name": move.product_id.name},
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ },
+ }
+ return return_vals
+
+ def validate(self, package_level_id, location_name, confirmation=False):
+ package = self.env["stock.package_level"].browse(package_level_id)
+ move = package.move_line_ids[0].move_id
+ dest_location = self.env["stock.location"].search(
+ [("name", "=", location_name)]
+ )
+ move_dest_location = move.move_line_ids[0].location_dest_id
+ allowed_locations = self.env["stock.location"].search(
+ [
+ (
+ "id",
+ "child_of",
+ move.picking_id.picking_type_id.default_location_dest_id.id,
+ )
+ ]
+ )
+ zone_locations = self.env["stock.location"].search(
+ [("id", "child_of", move_dest_location.id)]
+ )
+ if dest_location not in allowed_locations:
+ return {
+ "success": False,
+ "code": "forbidden",
+ "message": {"title": "Forbidden", "body": "You cannot place it here"},
+ }
+ elif (
+ dest_location in allowed_locations
+ and dest_location not in zone_locations
+ and confirmation
+ ):
+ return {
+ "success": False,
+ "code": "need_confirmation",
+ "message": {"title": "Confirm", "body": "Are you sure ?"},
+ }
+ if move.state == "cancel":
+ return {
+ "success": False,
+ "code": "restart",
+ "message": {
+ "title": "Restart",
+ "body": "Restart the operation someone has canceld it.",
+ },
+ }
+ move.move_line_ids[0].location_dest_id = dest_location.id
+ move._action_done()
+ return {"success": True}
+
+ def cancel(self, package_level_id):
+ package = self.env["stock.package_level"].browse(package_level_id)
+ package.move_ids[0].cancel()
+ return {"success": True}
+
+ def _validator_cancel(self):
+ return {
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}
+ }
+
+ def _validator_validate(self):
+ return {
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "location_name": {"type": "string", "nullable": False, "required": True},
+ }
+
+ def _validator_return_validate(self):
+ return {
+ "success": {"type": "boolean", "nullable": True, "required": True},
+ "code": {"type": "string", "nullable": True, "required": False},
+ "message": {
+ "type": "dict",
+ "schema": {
+ "body": {"type": "string", "nullable": False, "required": True}
+ },
+ },
+ }
+
+ def _validator_scan(self):
+ return {"barcode": {"type": "string", "nullable": False, "required": True}}
+
+ def _validator_return_scan(self):
+ return {
+ "success": {"type": "boolean", "nullable": True, "required": True},
+ "code": {"type": "string", "nullable": True, "required": False},
+ "message": {
+ "type": "dict",
+ "schema": {
+ "body": {"type": "string", "nullable": False, "required": True}
+ },
+ },
+ "data": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "location_src": {
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ "location_dst": {
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ "product": {
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ },
+ },
+ }
diff --git a/shopfloor/tests/test_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
similarity index 100%
rename from shopfloor/tests/test_putaway.py
rename to shopfloor/tests/test_single_pack_putaway.py
From 2fdb0e16854064325b262beb2a29ab6885a9389e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 23 Jan 2020 11:41:42 +0100
Subject: [PATCH 043/940] Use a unified response format (use
self._response_schema() now)
---
shopfloor/services/location.py | 50 ++++++----
shopfloor/services/menu.py | 41 +++++---
shopfloor/services/pack.py | 19 ++--
shopfloor/services/profile.py | 56 ++++++-----
shopfloor/services/service.py | 21 +++++
shopfloor/services/single_pack_putaway.py | 110 ++++++----------------
6 files changed, 147 insertions(+), 150 deletions(-)
diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py
index 17aac7be05..2a3f5ea355 100644
--- a/shopfloor/services/location.py
+++ b/shopfloor/services/location.py
@@ -28,7 +28,7 @@ def search(self, name_fragment=None):
]
)
records = self.env[self._expose_model].search(domain)
- return {"size": len(records), "data": self._to_json(records)}
+ return {"data": {"size": len(records), "records": self._to_json(records)}}
def _get_base_search_domain(self):
# TODO add filter on warehouse of the current profile
@@ -40,29 +40,39 @@ def _validator_search(self):
}
def _validator_return_search(self):
- return {
- "size": {"coerce": to_int, "required": True, "type": "integer"},
- "data": {
- "type": "list",
- "schema": {
- "type": "dict",
+ return self._response_schema(
+ {
+ "size": {"coerce": to_int, "required": True, "type": "integer"},
+ "records": {
+ "type": "list",
"schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "complete_name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- "barcode": {
- "type": "string",
- "nullable": False,
- "required": False,
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ "complete_name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ "barcode": {
+ "type": "string",
+ "nullable": False,
+ "required": False,
+ },
},
},
},
- },
- }
+ }
+ )
def _convert_one_record(self, record):
return {
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 220003a559..374772d9c2 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -39,7 +39,7 @@ def search(self, name_fragment=None):
if name_fragment:
domain.append(("name", "ilike", name_fragment))
records = self.env[self._expose_model].search(domain)
- return {"size": len(records), "data": self._to_json(records)}
+ return {"data": {"size": len(records), "records": self._to_json(records)}}
def _validator_search(self):
return {
@@ -47,24 +47,35 @@ def _validator_search(self):
}
def _validator_return_search(self):
- return {
- "size": {"coerce": to_int, "required": True, "type": "integer"},
- "data": {
- "type": "list",
- "schema": {
- "type": "dict",
+ return self._response_schema(
+ {
+ "size": {"coerce": to_int, "required": True, "type": "integer"},
+ "records": {
+ "type": "list",
+ "required": True,
"schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "process": {
- "type": "string",
- "nullable": False,
- "required": True,
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ "process": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
},
},
},
- },
- }
+ }
+ )
def _convert_one_record(self, record):
return {"id": record.id, "name": record.name, "process": record.process_code}
diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py
index 46b7014764..2f8a591621 100644
--- a/shopfloor/services/pack.py
+++ b/shopfloor/services/pack.py
@@ -25,7 +25,7 @@ def _validator_get_by_name(self):
return {"pack_name": {"type": "string", "nullable": False, "required": True}}
def _validator_return_get_by_name(self):
- return {"data": self._record_return_schema}
+ return self._response_schema(self._record_return_schema)
def _convert_one_record(self, record):
return {
@@ -37,16 +37,13 @@ def _convert_one_record(self, record):
@property
def _record_return_schema(self):
return {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "location": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "location": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
},
}
diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py
index 92f9d3eac3..2ccf78be0f 100644
--- a/shopfloor/services/profile.py
+++ b/shopfloor/services/profile.py
@@ -31,7 +31,7 @@ def search(self, name_fragment=None):
if name_fragment:
domain.append(("name", "ilike", name_fragment))
records = self.env[self._expose_model].search(domain)
- return {"size": len(records), "data": self._to_json(records)}
+ return {"data": {"size": len(records), "records": self._to_json(records)}}
def _get_base_search_domain(self):
# shopfloor_profile_ids is a one2one in practice.
@@ -58,34 +58,44 @@ def _validator_search(self):
}
def _validator_return_search(self):
- return {
- "size": {"coerce": to_int, "required": True, "type": "integer"},
- "data": {
- "type": "list",
- "schema": {
- "type": "dict",
+ return self._response_schema(
+ {
+ "size": {"coerce": to_int, "required": True, "type": "integer"},
+ "records": {
+ "type": "list",
"schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "warehouse": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ "warehouse": {
+ "type": "dict",
+ "schema": {
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
},
},
},
},
},
- },
- }
+ }
+ )
def _convert_one_record(self, record):
return {
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 2363b60af4..91620efc21 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -37,6 +37,27 @@ def _to_json(self, records):
res.append(self._convert_one_record(record))
return res
+ def _response_schema(self, data_schema=None):
+ if not data_schema:
+ data_schema = {}
+ return {
+ "data": {"type": "dict", "required": False, "schema": data_schema},
+ "state": {"type": "string", "required": False},
+ "message": {
+ "type": "dict",
+ "required": False,
+ "schema": {
+ "message_type": {
+ "type": "string",
+ "required": True,
+ "allowed": ["info", "warning", "error"],
+ },
+ "title": {"type": "string", "required": False},
+ "message": {"type": "string", "required": True},
+ },
+ },
+ }
+
def _get_openapi_default_parameters(self):
defaults = super()._get_openapi_default_parameters()
demo_api_key = self.env.ref(
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 3c68068915..a5a982c370 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -198,94 +198,42 @@ def _validator_validate(self):
}
def _validator_return_validate(self):
- return {
- "success": {"type": "boolean", "nullable": True, "required": True},
- "code": {"type": "string", "nullable": True, "required": False},
- "message": {
- "type": "dict",
- "schema": {
- "body": {"type": "string", "nullable": False, "required": True}
- },
- },
- }
+ return self._response_schema()
def _validator_scan(self):
return {"barcode": {"type": "string", "nullable": False, "required": True}}
def _validator_return_scan(self):
- return {
- "success": {"type": "boolean", "nullable": True, "required": True},
- "code": {"type": "string", "nullable": True, "required": False},
- "message": {
- "type": "dict",
- "schema": {
- "body": {"type": "string", "nullable": False, "required": True}
- },
- },
- "data": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "location_src": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
+ return self._response_schema(
+ {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "location_src": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
- "location_dst": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
+ },
+ "location_dst": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
- "product": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
+ },
+ "product": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
- "picking": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
+ },
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
},
- },
- }
+ }
+ )
From 651b4a34dcddcdbc8f208446283aa1e8bb774265 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 23 Jan 2020 12:01:51 +0100
Subject: [PATCH 044/940] Add method to generate the response body (use
self._response())
---
shopfloor/services/location.py | 4 +++-
shopfloor/services/menu.py | 4 +++-
shopfloor/services/pack.py | 2 +-
shopfloor/services/profile.py | 4 +++-
shopfloor/services/service.py | 26 ++++++++++++++++++++++++++
5 files changed, 36 insertions(+), 4 deletions(-)
diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py
index 2a3f5ea355..a789869120 100644
--- a/shopfloor/services/location.py
+++ b/shopfloor/services/location.py
@@ -28,7 +28,9 @@ def search(self, name_fragment=None):
]
)
records = self.env[self._expose_model].search(domain)
- return {"data": {"size": len(records), "records": self._to_json(records)}}
+ return self._response(
+ data={"size": len(records), "records": self._to_json(records)}
+ )
def _get_base_search_domain(self):
# TODO add filter on warehouse of the current profile
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 374772d9c2..33ea065b67 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -39,7 +39,9 @@ def search(self, name_fragment=None):
if name_fragment:
domain.append(("name", "ilike", name_fragment))
records = self.env[self._expose_model].search(domain)
- return {"data": {"size": len(records), "records": self._to_json(records)}}
+ return self._response(
+ data={"size": len(records), "records": self._to_json(records)}
+ )
def _validator_search(self):
return {
diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py
index 2f8a591621..e13ea02c5e 100644
--- a/shopfloor/services/pack.py
+++ b/shopfloor/services/pack.py
@@ -19,7 +19,7 @@ def get_by_name(self, pack_name):
# TODO, is it what we want? error if not found?
limit=1,
)
- return self._to_json(pack)[:1]
+ return self._response(data=self._to_json(pack)[:1])
def _validator_get_by_name(self):
return {"pack_name": {"type": "string", "nullable": False, "required": True}}
diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py
index 2ccf78be0f..86e2ed1175 100644
--- a/shopfloor/services/profile.py
+++ b/shopfloor/services/profile.py
@@ -31,7 +31,9 @@ def search(self, name_fragment=None):
if name_fragment:
domain.append(("name", "ilike", name_fragment))
records = self.env[self._expose_model].search(domain)
- return {"data": {"size": len(records), "records": self._to_json(records)}}
+ return self._response(
+ data={"size": len(records), "records": self._to_json(records)}
+ )
def _get_base_search_domain(self):
# shopfloor_profile_ids is a one2one in practice.
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 91620efc21..d96822cb43 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -37,7 +37,33 @@ def _to_json(self, records):
res.append(self._convert_one_record(record))
return res
+ def _response(self, data=None, state=None, message=None):
+ """Base "envelope" for the responses
+
+ All the keys are optional.
+
+ :param data: dictionary of values
+ :param state: string describing the next state that the client
+ application must reach
+ :param message: dictionary for the message to show in the client
+ application (see ``_response_schema`` for the keys)
+ """
+ response = {}
+ if data:
+ response["data"] = data
+ if state:
+ response["state"] = state
+ if message:
+ response["message"] = message
+ return response
+
def _response_schema(self, data_schema=None):
+ """Schema for the return validator
+
+ Must be used for the schema of all responses.
+ The "data" part can be customized and is optional,
+ it must be a dictionary.
+ """
if not data_schema:
data_schema = {}
return {
From a43a4c5d8298289e19d5992879fb60284b48e467 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Thu, 23 Jan 2020 11:53:04 +0100
Subject: [PATCH 045/940] fix tests
---
shopfloor/services/single_pack_putaway.py | 14 +++++++++++---
shopfloor/tests/__init__.py | 2 +-
shopfloor/tests/test_single_pack_putaway.py | 6 +++---
3 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index a5a982c370..9192526ebc 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -95,11 +95,12 @@ def scan(self, barcode):
product = pack.quant_ids[
0
].product_id # FIXME we consider only one product per pack
+ default_location_dest = picking_type.default_location_dest_id
move_vals = {
"picking_type_id": picking_type.id,
"product_id": product.id,
"location_id": pack.location_id.id,
- "location_dest_id": picking_type.default_location_dest_id.id,
+ "location_dest_id": default_location_dest.id,
"name": product.name,
"product_uom": product.uom_id.id,
"product_uom_qty": pack.quant_ids[0].quantity,
@@ -107,11 +108,18 @@ def scan(self, barcode):
}
move = self.env["stock.move"].create(move_vals)
move._action_confirm()
+ location_dest_id = (
+ default_location_dest._get_putaway_strategy(product).id
+ or default_location_dest.id
+ )
self.env["stock.package_level"].create(
{
"package_id": pack.id,
- "move_ids": [(6, 0, [move.id])],
+ "move_ids": [(4, move.id)],
"company_id": company.id,
+ "is_done": True,
+ "location_id": pack.location_id.id,
+ "location_dest_id": location_dest_id,
}
)
move.picking_id.action_assign()
@@ -127,7 +135,7 @@ def scan(self, barcode):
"id": move.move_line_ids[0].location_dest_id.id,
"name": move.move_line_ids[0].location_dest_id.name,
},
- "product": {"id": move.product_id.name, "name": move.product_id.name},
+ "product": {"id": move.product_id.id, "name": move.product_id.name},
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
},
}
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 3a80e1ce96..188a3788ab 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -1 +1 @@
-from . import test_putaway
+from . import test_single_pack_putaway
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 5b09c33241..82a8e6c6c2 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -5,6 +5,7 @@ class PutawayCase(CommonCase):
def setUp(self, *args, **kwargs):
super(PutawayCase, self).setUp(*args, **kwargs)
stock_location = self.env.ref("stock.stock_location_stock")
+ out_location = stock_location.child_ids[1]
self.productA = self.env["product.product"].create(
{"name": "Product A", "type": "product"}
)
@@ -23,12 +24,11 @@ def setUp(self, *args, **kwargs):
{
"product_id": self.productA.id,
"location_in_id": stock_location.id,
- "location_out_id": stock_location.child_ids[0].id,
+ "location_out_id": out_location.id,
}
)
-
with self.work_on_services() as work:
- self.service = work.component(usage="pack")
+ self.service = work.component(usage="single_pack_putaway")
def test_scan_pack(self):
barcode = self.packA.name
From 6166b4a66c0e6cf66807e8901e19c8ddb088651a Mon Sep 17 00:00:00 2001
From: Benoit
Date: Thu, 23 Jan 2020 12:20:53 +0100
Subject: [PATCH 046/940] fix return format
---
shopfloor/services/single_pack_putaway.py | 78 +++++++++++++++--------
1 file changed, 51 insertions(+), 27 deletions(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 9192526ebc..18de0687df 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -24,11 +24,11 @@ def scan(self, barcode):
pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
if not pack:
return {
- "success": False,
- "code": "not_found",
+ "state": "start",
"message": {
+ "message_type": "error",
"title": "Pack not found",
- "body": "the pack %s doesn't exists" % barcode,
+ "message": "the pack %s doesn't exists" % barcode,
},
}
allowed_locations = self.env["stock.location"].search(
@@ -36,11 +36,11 @@ def scan(self, barcode):
)
if pack.location_id not in allowed_locations:
return {
- "success": False,
- "code": "forbidden",
+ "state": "start",
"message": {
+ "message_type": "error",
"title": "do not process",
- "body": "pack %s is not in %s location"
+ "message": "pack %s is not in %s location"
% (barcode, picking_type.default_location_src_id.name),
},
}
@@ -53,11 +53,11 @@ def scan(self, barcode):
and existing_operations[0].picking_id.picking_type_id != picking_type
):
return {
- "success": False,
- "code": "forbidden",
+ "state": "start",
"message": {
+ "message_type": "error",
"title": "do not process",
- "body": "An operation exists in %s %s. "
+ "message": "An operation exists in %s %s. "
"You cannot process it with this shopfloor process."
% (
existing_operations[0].picking_id.picking_type_id.name,
@@ -84,11 +84,11 @@ def scan(self, barcode):
},
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
},
- "success": False,
- "code": "need_confirmation",
+ "state": "confirm_start",
"message": {
+ "message_type": "warning",
"title": "Already started",
- "body": "Operation already running. "
+ "message": "Operation already running. "
"Would you like to take it over ?",
},
}
@@ -107,7 +107,7 @@ def scan(self, barcode):
"company_id": company.id,
}
move = self.env["stock.move"].create(move_vals)
- move._action_confirm()
+ move._action_confirm(merge=False)
location_dest_id = (
default_location_dest._get_putaway_strategy(product).id
or default_location_dest.id
@@ -123,8 +123,13 @@ def scan(self, barcode):
}
)
move.picking_id.action_assign()
- return_vals = {
- "success": True,
+ return {
+ "state": "scan_location",
+ "message": {
+ "message_type": "info",
+ "title": "Start",
+ "message": "The move is ready, you can scan the destination location.",
+ },
"data": {
"id": move.move_line_ids[0].package_level_id.id,
"location_src": {
@@ -139,7 +144,6 @@ def scan(self, barcode):
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
},
}
- return return_vals
def validate(self, package_level_id, location_name, confirmation=False):
package = self.env["stock.package_level"].browse(package_level_id)
@@ -162,9 +166,12 @@ def validate(self, package_level_id, location_name, confirmation=False):
)
if dest_location not in allowed_locations:
return {
- "success": False,
- "code": "forbidden",
- "message": {"title": "Forbidden", "body": "You cannot place it here"},
+ "state": "scan_location",
+ "message": {
+ "message_type": "error",
+ "title": "Forbidden",
+ "message": "You cannot place it here",
+ },
}
elif (
dest_location in allowed_locations
@@ -172,27 +179,44 @@ def validate(self, package_level_id, location_name, confirmation=False):
and confirmation
):
return {
- "success": False,
- "code": "need_confirmation",
- "message": {"title": "Confirm", "body": "Are you sure ?"},
+ "state": "confirm_location",
+ "message": {
+ "message_type": "warning",
+ "title": "Confirm",
+ "message": "Are you sure ?",
+ },
}
if move.state == "cancel":
return {
- "success": False,
- "code": "restart",
+ "state": "start",
"message": {
+ "message_type": "warning",
"title": "Restart",
- "body": "Restart the operation someone has canceld it.",
+ "message": "Restart the operation someone has canceled it.",
},
}
move.move_line_ids[0].location_dest_id = dest_location.id
move._action_done()
- return {"success": True}
+ return {
+ "state": "start",
+ "message": {
+ "message_type": "info",
+ "title": "Start",
+ "message": "The pack has been moved, you can scan a new pack.",
+ },
+ }
def cancel(self, package_level_id):
package = self.env["stock.package_level"].browse(package_level_id)
package.move_ids[0].cancel()
- return {"success": True}
+ return {
+ "state": "start",
+ "message": {
+ "message_type": "info",
+ "title": "Start",
+ "message": "The move has been canceled, you can scan a new pack.",
+ },
+ }
def _validator_cancel(self):
return {
From c90c8c4b25569d306619e9e37b5dc25491c3744c Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 23 Jan 2020 13:30:37 +0100
Subject: [PATCH 047/940] Add translations gettext on messages
---
shopfloor/services/single_pack_putaway.py | 144 ++++++++++++----------
1 file changed, 80 insertions(+), 64 deletions(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 18de0687df..80d20d5a3d 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -1,3 +1,5 @@
+from odoo import _
+
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -23,27 +25,27 @@ def scan(self, barcode):
# TODO define on what we search (pack name, pack barcode ...)
pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
if not pack:
- return {
- "state": "start",
- "message": {
+ return self._response(
+ state="start",
+ message={
"message_type": "error",
- "title": "Pack not found",
- "message": "the pack %s doesn't exists" % barcode,
+ "title": _("Pack not found"),
+ "message": _("The pack %s doesn't exist") % barcode,
},
- }
+ )
allowed_locations = self.env["stock.location"].search(
[("id", "child_of", picking_type.default_location_src_id.id)]
)
if pack.location_id not in allowed_locations:
- return {
- "state": "start",
- "message": {
+ return self._response(
+ state="start",
+ message={
"message_type": "error",
- "title": "do not process",
- "message": "pack %s is not in %s location"
+ "title": _("Cannot proceed"),
+ "message": _("pack %s is not in %s location")
% (barcode, picking_type.default_location_src_id.name),
},
- }
+ )
quantity = pack.quant_ids[0].quantity
existing_operations = self.env["stock.move.line"].search(
[("qty_done", "=", quantity), ("package_id", "=", pack.id)]
@@ -52,23 +54,25 @@ def scan(self, barcode):
existing_operations
and existing_operations[0].picking_id.picking_type_id != picking_type
):
- return {
- "state": "start",
- "message": {
+ return self._response(
+ state="start",
+ message={
"message_type": "error",
- "title": "do not process",
- "message": "An operation exists in %s %s. "
- "You cannot process it with this shopfloor process."
+ "title": _("Cannot proceed"),
+ "message": _(
+ "An operation exists in %s %s. "
+ "You cannot process it with this shopfloor process."
+ )
% (
existing_operations[0].picking_id.picking_type_id.name,
existing_operations[0].picking_id.name,
),
},
- }
+ )
elif existing_operations:
move = existing_operations.move_id
- return {
- "data": {
+ return self._response(
+ data={
"id": move.move_line_ids[0].package_level_id.id,
"location_src": {
"id": pack.location_id.id,
@@ -84,14 +88,15 @@ def scan(self, barcode):
},
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
},
- "state": "confirm_start",
- "message": {
+ state="confirm_start",
+ message={
"message_type": "warning",
- "title": "Already started",
- "message": "Operation already running. "
- "Would you like to take it over ?",
+ "title": _("Already started"),
+ "message": _(
+ "Operation already running. " "Would you like to take it over ?"
+ ),
},
- }
+ )
product = pack.quant_ids[
0
].product_id # FIXME we consider only one product per pack
@@ -123,14 +128,16 @@ def scan(self, barcode):
}
)
move.picking_id.action_assign()
- return {
- "state": "scan_location",
- "message": {
+ return self._response(
+ state="scan_location",
+ message={
"message_type": "info",
- "title": "Start",
- "message": "The move is ready, you can scan the destination location.",
+ "title": _("Start"),
+ "message": _(
+ "The move is ready, you can scan the destination location."
+ ),
},
- "data": {
+ data={
"id": move.move_line_ids[0].package_level_id.id,
"location_src": {
"id": pack.location_id.id,
@@ -143,7 +150,7 @@ def scan(self, barcode):
"product": {"id": move.product_id.id, "name": move.product_id.name},
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
},
- }
+ )
def validate(self, package_level_id, location_name, confirmation=False):
package = self.env["stock.package_level"].browse(package_level_id)
@@ -165,58 +172,67 @@ def validate(self, package_level_id, location_name, confirmation=False):
[("id", "child_of", move_dest_location.id)]
)
if dest_location not in allowed_locations:
- return {
- "state": "scan_location",
- "message": {
+ return self._response(
+ state="scan_location",
+ message={
"message_type": "error",
- "title": "Forbidden",
- "message": "You cannot place it here",
+ "title": _("Forbidden"),
+ "message": _("You cannot place it here"),
},
- }
+ )
elif (
dest_location in allowed_locations
and dest_location not in zone_locations
and confirmation
):
- return {
- "state": "confirm_location",
- "message": {
+ return self._response(
+ state="confirm_location",
+ message={
"message_type": "warning",
- "title": "Confirm",
- "message": "Are you sure ?",
+ "title": _("Confirm"),
+ "message": _("Are you sure?"),
},
- }
+ )
if move.state == "cancel":
- return {
- "state": "start",
- "message": {
+ return self._response(
+ state="start",
+ message={
"message_type": "warning",
- "title": "Restart",
- "message": "Restart the operation someone has canceled it.",
+ "title": _("Restart"),
+ "message": _("Restart the operation, someone has canceled it."),
},
- }
+ )
move.move_line_ids[0].location_dest_id = dest_location.id
move._action_done()
- return {
- "state": "start",
- "message": {
+ return self._response(
+ state="start",
+ message={
"message_type": "info",
- "title": "Start",
- "message": "The pack has been moved, you can scan a new pack.",
+ "title": _("Start"),
+ "message": _("The pack has been moved, you can scan a new pack."),
},
- }
+ )
def cancel(self, package_level_id):
package = self.env["stock.package_level"].browse(package_level_id)
+ if not package.exists():
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Start again"),
+ "message": _("This operation does not exist anymore."),
+ },
+ )
package.move_ids[0].cancel()
- return {
- "state": "start",
- "message": {
+ return self._response(
+ state="start",
+ message={
"message_type": "info",
- "title": "Start",
- "message": "The move has been canceled, you can scan a new pack.",
+ "title": _("Start"),
+ "message": _("The move has been canceled, you can scan a new pack."),
},
- }
+ )
def _validator_cancel(self):
return {
From 02d1fd7d5dfbd2ed858961ac0e71caa9ea15f0a1 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Thu, 23 Jan 2020 14:09:48 +0100
Subject: [PATCH 048/940] use location barcode instead of name
---
shopfloor/services/single_pack_putaway.py | 49 ++++++++++-----------
shopfloor/tests/test_single_pack_putaway.py | 22 +++++----
2 files changed, 37 insertions(+), 34 deletions(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 80d20d5a3d..52f3994b3e 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -152,11 +152,20 @@ def scan(self, barcode):
},
)
- def validate(self, package_level_id, location_name, confirmation=False):
+ def validate(self, package_level_id, location_barcode, confirmation=False):
package = self.env["stock.package_level"].browse(package_level_id)
move = package.move_line_ids[0].move_id
+ if move.state == "cancel":
+ return self._response(
+ state="start",
+ message={
+ "message_type": "warning",
+ "title": _("Restart"),
+ "message": _("Restart the operation, someone has canceled it."),
+ },
+ )
dest_location = self.env["stock.location"].search(
- [("name", "=", location_name)]
+ [("barcode", "=", location_barcode)]
)
move_dest_location = move.move_line_ids[0].location_dest_id
allowed_locations = self.env["stock.location"].search(
@@ -180,28 +189,18 @@ def validate(self, package_level_id, location_name, confirmation=False):
"message": _("You cannot place it here"),
},
)
- elif (
- dest_location in allowed_locations
- and dest_location not in zone_locations
- and confirmation
- ):
- return self._response(
- state="confirm_location",
- message={
- "message_type": "warning",
- "title": _("Confirm"),
- "message": _("Are you sure?"),
- },
- )
- if move.state == "cancel":
- return self._response(
- state="start",
- message={
- "message_type": "warning",
- "title": _("Restart"),
- "message": _("Restart the operation, someone has canceled it."),
- },
- )
+ elif dest_location in allowed_locations and dest_location not in zone_locations:
+ if confirmation:
+ move.location_dest_id = dest_location.id
+ else:
+ return self._response(
+ state="confirm_location",
+ message={
+ "message_type": "warning",
+ "title": _("Confirm"),
+ "message": _("Are you sure?"),
+ },
+ )
move.move_line_ids[0].location_dest_id = dest_location.id
move._action_done()
return self._response(
@@ -242,7 +241,7 @@ def _validator_cancel(self):
def _validator_validate(self):
return {
"package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
- "location_name": {"type": "string", "nullable": False, "required": True},
+ "location_barcode": {"type": "string", "nullable": False, "required": True},
}
def _validator_return_validate(self):
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 82a8e6c6c2..7d2057b370 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -5,7 +5,13 @@ class PutawayCase(CommonCase):
def setUp(self, *args, **kwargs):
super(PutawayCase, self).setUp(*args, **kwargs)
stock_location = self.env.ref("stock.stock_location_stock")
- out_location = stock_location.child_ids[1]
+ out_location = self.env["stock.location"].search(
+ [
+ ("location_id", "=", stock_location.id),
+ ("barcode", "!=", False),
+ ("usage", "=", "internal"),
+ ]
+ )[0]
self.productA = self.env["product.product"].create(
{"name": "Product A", "type": "product"}
)
@@ -36,19 +42,17 @@ def test_scan_pack(self):
response = self.service.dispatch("scan", params=params)
package_level = self.env["stock.package_level"].browse(response["data"]["id"])
move_id = package_level.move_line_ids[0].move_id.id
+ location_dest = self.env["stock.location"].browse(
+ response["data"]["location_dst"]["id"]
+ )
params = {
"package_level_id": package_level.id,
- "location_name": response["data"]["location_dst"]["name"],
+ "location_barcode": location_dest.barcode,
}
- location_dest_id = (
- self.env["stock.location"]
- .search([("name", "=", params["location_name"])])
- .id
- )
new_loc_quant = self.env["stock.quant"].search(
[
("product_id", "=", self.productA.id),
- ("location_id", "=", location_dest_id),
+ ("location_id", "=", location_dest.id),
]
)
self.assertFalse(new_loc_quant)
@@ -56,7 +60,7 @@ def test_scan_pack(self):
new_loc_quant = self.env["stock.quant"].search(
[
("product_id", "=", self.productA.id),
- ("location_id", "=", location_dest_id),
+ ("location_id", "=", location_dest.id),
]
)
move = self.env["stock.move"].browse(move_id)
From 04ce7f5415dde617641ed8286def313ded48f77e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 23 Jan 2020 14:39:48 +0100
Subject: [PATCH 049/940] Add Actions Components to share business logic
Service Components are methods exposed to the REST API.
They need to share common methods for their business logic,
so enter the Actions Components to hold them.
Alternatively, we could have put the methods directly on the Models,
but at least here we don't crowd the models with many methods.
To create a new Action Component, the bare minimum is:
from odoo.addons.component.core import Component
class StockPackageLevelAction(Component):
_name = "shopfloor.stock.package_level.action"
_inherit = "shopfloor.process.action"
_apply_on = "stock.package_level"
def hello(self):
return "world"
And to use it from any Service or Action component:
self.actions_for("stock.package_level").hello()
---
shopfloor/__init__.py | 1 +
shopfloor/actions/__init__.py | 21 ++++
shopfloor/actions/base_action.py | 10 ++
shopfloor/actions/stock_package_level.py | 10 ++
shopfloor/services/service.py | 34 +++++-
shopfloor/services/single_pack_putaway.py | 122 +++++++++++++++++----
shopfloor/services/single_pack_transfer.py | 12 ++
7 files changed, 187 insertions(+), 23 deletions(-)
create mode 100644 shopfloor/actions/__init__.py
create mode 100644 shopfloor/actions/base_action.py
create mode 100644 shopfloor/actions/stock_package_level.py
diff --git a/shopfloor/__init__.py b/shopfloor/__init__.py
index c312a8487c..6a34e5681f 100644
--- a/shopfloor/__init__.py
+++ b/shopfloor/__init__.py
@@ -1,3 +1,4 @@
from . import controllers
from . import models
+from . import actions
from . import services
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
new file mode 100644
index 0000000000..b9b5c7e551
--- /dev/null
+++ b/shopfloor/actions/__init__.py
@@ -0,0 +1,21 @@
+"""
+Support actions available from any Service Components.
+
+To use an Action Component, a Service component
+
+Difference with Service components:
+
+* Public methods of a Service Components are exposed in the REST API,
+ Action Components are never exposed
+* Action Components will generally have an existing Odoo model name
+
+An Action component can be get from Service or Action Components using
+``self.actions_for(model_name)``.
+
+The goal of the Action Components is to share common actions
+and processes between Services, avoid having too much logic in
+Services.
+
+"""
+from . import base_action
+from . import stock_package_level
diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py
new file mode 100644
index 0000000000..e7d5bbda5b
--- /dev/null
+++ b/shopfloor/actions/base_action.py
@@ -0,0 +1,10 @@
+from odoo.addons.component.core import AbstractComponent
+
+
+class ShopFloorProcessAction(AbstractComponent):
+ _name = "shopfloor.process.action"
+ _collection = "shopfloor.action"
+ _usage = "actions"
+
+ def actions_for(self, model_name):
+ return self.component(usage="actions", model_name=model_name)
diff --git a/shopfloor/actions/stock_package_level.py b/shopfloor/actions/stock_package_level.py
new file mode 100644
index 0000000000..dd48a12da6
--- /dev/null
+++ b/shopfloor/actions/stock_package_level.py
@@ -0,0 +1,10 @@
+from odoo.addons.component.core import Component
+
+
+class StockPackageLevelAction(Component):
+ _name = "shopfloor.stock.package_level.action"
+ _inherit = "shopfloor.process.action"
+ _apply_on = "stock.package_level"
+
+ def hello(self):
+ return "world"
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index d96822cb43..f9bfd90518 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -2,7 +2,8 @@
from odoo.exceptions import MissingError
from odoo.osv import expression
-from odoo.addons.component.core import AbstractComponent
+from odoo.addons.base_rest.controllers.main import _PseudoCollection
+from odoo.addons.component.core import AbstractComponent, WorkContext
class BaseShopfloorService(AbstractComponent):
@@ -11,6 +12,7 @@ class BaseShopfloorService(AbstractComponent):
_inherit = "base.rest.service"
_name = "base.shopfloor.service"
_collection = "shopfloor.service"
+ _actions_collection_name = "shopfloor.action"
_expose_model = None
def _get(self, _id):
@@ -121,3 +123,33 @@ def _get_openapi_default_parameters(self):
]
)
return defaults
+
+ @property
+ def actions_collection(self):
+ return _PseudoCollection(self._actions_collection_name, self.env)
+
+ def actions_for(self, model_name):
+ """Return an Action Component for the model
+
+ Action Components are the components supporting the business logic of
+ the processes, so we can limit the code in Services to the minimum and
+ share methods.
+ """
+ # propagate custom arguments (such as menu ID/profile ID)
+ kwargs = {
+ attr_name: getattr(self.work, attr_name)
+ for attr_name in self.work._propagate_kwargs
+ if attr_name
+ not in ("collection", "model_name", "components_registry", "model_name")
+ }
+ work = WorkContext(
+ model_name=model_name, collection=self.actions_collection, **kwargs
+ )
+ return work.component(usage="actions")
+
+ def _is_public_api_method(self, method_name):
+ # do not "hide" the "actions_for" method as internal since, we'll use
+ # it in components, so exclude it from the rest API
+ if method_name == "actions_for":
+ return False
+ return super()._is_public_api_method(method_name)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 52f3994b3e..cbf6154eb3 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -15,7 +15,9 @@ class SinglePackPutaway(Component):
def scan(self, barcode):
"""Scan a pack barcode"""
- company = self.env.user.company_id # FIXME add logic to get proper company
+ company = (
+ self.env.user.company_id
+ ) # FIXME add logic to get proper company
# FIXME add logic to get proper warehouse
warehouse = self.env["stock.warehouse"].search([])[0]
picking_type = (
@@ -52,7 +54,8 @@ def scan(self, barcode):
)
if (
existing_operations
- and existing_operations[0].picking_id.picking_type_id != picking_type
+ and existing_operations[0].picking_id.picking_type_id
+ != picking_type
):
return self._response(
state="start",
@@ -86,14 +89,18 @@ def scan(self, barcode):
"id": move.product_id.name,
"name": move.product_id.name,
},
- "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ "picking": {
+ "id": move.picking_id.id,
+ "name": move.picking_id.name,
+ },
},
state="confirm_start",
message={
"message_type": "warning",
"title": _("Already started"),
"message": _(
- "Operation already running. " "Would you like to take it over ?"
+ "Operation already running. "
+ "Would you like to take it over ?"
),
},
)
@@ -147,13 +154,29 @@ def scan(self, barcode):
"id": move.move_line_ids[0].location_dest_id.id,
"name": move.move_line_ids[0].location_dest_id.name,
},
- "product": {"id": move.product_id.id, "name": move.product_id.name},
- "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ "product": {
+ "id": move.product_id.id,
+ "name": move.product_id.name,
+ },
+ "picking": {
+ "id": move.picking_id.id,
+ "name": move.picking_id.name,
+ },
},
)
def validate(self, package_level_id, location_barcode, confirmation=False):
+ """Validate the transfer"""
package = self.env["stock.package_level"].browse(package_level_id)
+ if not package.exists():
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Start again"),
+ "message": _("This operation does not exist anymore."),
+ },
+ )
move = package.move_line_ids[0].move_id
if move.state == "cancel":
return self._response(
@@ -161,7 +184,9 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
message={
"message_type": "warning",
"title": _("Restart"),
- "message": _("Restart the operation, someone has canceled it."),
+ "message": _(
+ "Restart the operation, someone has canceled it."
+ ),
},
)
dest_location = self.env["stock.location"].search(
@@ -189,7 +214,10 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
"message": _("You cannot place it here"),
},
)
- elif dest_location in allowed_locations and dest_location not in zone_locations:
+ elif (
+ dest_location in allowed_locations
+ and dest_location not in zone_locations
+ ):
if confirmation:
move.location_dest_id = dest_location.id
else:
@@ -208,7 +236,9 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
message={
"message_type": "info",
"title": _("Start"),
- "message": _("The pack has been moved, you can scan a new pack."),
+ "message": _(
+ "The pack has been moved, you can scan a new pack."
+ ),
},
)
@@ -229,26 +259,42 @@ def cancel(self, package_level_id):
message={
"message_type": "info",
"title": _("Start"),
- "message": _("The move has been canceled, you can scan a new pack."),
+ "message": _(
+ "The move has been canceled, you can scan a new pack."
+ ),
},
)
def _validator_cancel(self):
return {
- "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}
+ "package_level_id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ }
}
def _validator_validate(self):
return {
- "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
- "location_barcode": {"type": "string", "nullable": False, "required": True},
+ "package_level_id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "location_barcode": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
}
def _validator_return_validate(self):
return self._response_schema()
def _validator_scan(self):
- return {"barcode": {"type": "string", "nullable": False, "required": True}}
+ return {
+ "barcode": {"type": "string", "nullable": False, "required": True}
+ }
def _validator_return_scan(self):
return self._response_schema(
@@ -257,29 +303,61 @@ def _validator_return_scan(self):
"location_src": {
"type": "dict",
"schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
},
},
"location_dst": {
"type": "dict",
"schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
},
},
"product": {
"type": "dict",
"schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
},
},
"picking": {
"type": "dict",
"schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
+ "id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
},
},
}
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index a22659a1cb..e29fdbf56c 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -1,3 +1,4 @@
+from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -8,3 +9,14 @@ class SinglePackTransfer(Component):
_name = "shopfloor.single.pack.transfer"
_usage = "single_pack_transfer"
_description = __doc__
+
+ def validate(self, package_level_id, location_name, confirmation=False):
+ """Validate the transfer"""
+ return self.actions_for("stock.package_level").hello()
+
+ def _validator_validate(self):
+ return {
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "location_name": {"type": "string", "nullable": False, "required": True},
+ "confirmation": {"type": "boolean", "required": False},
+ }
From 6a8f068eb0daacb7d813a1900d5afa64c274ad3b Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 23 Jan 2020 15:11:17 +0100
Subject: [PATCH 050/940] Add /app/load_config endpoint (load menus+profiles at
once)
---
shopfloor/services/__init__.py | 1 +
shopfloor/services/app.py | 42 ++++++++++++++++++++++++++
shopfloor/services/menu.py | 41 ++++++++++---------------
shopfloor/services/profile.py | 55 ++++++++++++++--------------------
4 files changed, 81 insertions(+), 58 deletions(-)
create mode 100644 shopfloor/services/app.py
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index bb82a1df2e..5eb564a6a6 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -1,4 +1,5 @@
from . import service
+from . import app
from . import profile
from . import menu
from . import pack
diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py
new file mode 100644
index 0000000000..ed1bbf4880
--- /dev/null
+++ b/shopfloor/services/app.py
@@ -0,0 +1,42 @@
+from odoo.addons.component.core import Component
+
+
+class ShopfloorApp(Component):
+ """Generic endpoints for the Application."""
+
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.app"
+ _usage = "app"
+ _description = __doc__
+
+ def user_config(self):
+ menus = self.component("menu")._search()
+ profiles = self.component("profile")._search()
+ return self._response(data={"menus": menus, "profiles": profiles})
+
+ def _validator_user_config(self):
+ return {}
+
+ def _validator_return_user_config(self):
+ menu_service = self.component("menu")
+ profile_service = self.component("profile")
+ return self._response_schema(
+ {
+ "menus": {
+ "type": "list",
+ "required": True,
+ "schema": {
+ "type": "dict",
+ "schema": menu_service._record_return_schema,
+ },
+ },
+ "profiles": {
+ "type": "list",
+ "required": True,
+ "schema": {
+ "type": "dict",
+ "schema": profile_service._record_return_schema,
+ },
+ },
+ }
+ )
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 33ea065b67..96847ce7c4 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -33,15 +33,17 @@ def _get_base_search_domain(self):
]
)
- def search(self, name_fragment=None):
- """List available menu entries for current user"""
+ def _search(self, name_fragment=None):
domain = self._get_base_search_domain()
if name_fragment:
domain.append(("name", "ilike", name_fragment))
records = self.env[self._expose_model].search(domain)
- return self._response(
- data={"size": len(records), "records": self._to_json(records)}
- )
+ return self._to_json(records)
+
+ def search(self, name_fragment=None):
+ """List available menu entries for current user"""
+ json_records = self._search(name_fragment=name_fragment)
+ return self._response(data={"size": len(json_records), "records": json_records})
def _validator_search(self):
return {
@@ -55,29 +57,18 @@ def _validator_return_search(self):
"records": {
"type": "list",
"required": True,
- "schema": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- "process": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
- },
+ "schema": {"type": "dict", "schema": self._record_return_schema},
},
}
)
+ @property
+ def _record_return_schema(self):
+ return {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "process": {"type": "string", "nullable": False, "required": True},
+ }
+
def _convert_one_record(self, record):
return {"id": record.id, "name": record.name, "process": record.process_code}
diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py
index 86e2ed1175..9f59ef0706 100644
--- a/shopfloor/services/profile.py
+++ b/shopfloor/services/profile.py
@@ -25,14 +25,18 @@ class ShopfloorProfile(Component):
_expose_model = "shopfloor.profile"
_description = __doc__
- def search(self, name_fragment=None):
- """List available profiles for current user"""
+ def _search(self, name_fragment=None):
domain = self._get_base_search_domain()
if name_fragment:
domain.append(("name", "ilike", name_fragment))
records = self.env[self._expose_model].search(domain)
+ return self._to_json(records)
+
+ def search(self, name_fragment=None):
+ """List available profiles for current user"""
+ json_records = self._search(name_fragment=name_fragment)
return self._response(
- data={"size": len(records), "records": self._to_json(records)}
+ data={"size": len(json_records), "records": self._to_json(json_records)}
)
def _get_base_search_domain(self):
@@ -65,40 +69,25 @@ def _validator_return_search(self):
"size": {"coerce": to_int, "required": True, "type": "integer"},
"records": {
"type": "list",
- "schema": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- "warehouse": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
- },
- },
- },
+ "schema": {"type": "dict", "schema": self._record_return_schema},
},
}
)
+ @property
+ def _record_return_schema(self):
+ return {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "warehouse": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ }
+
def _convert_one_record(self, record):
return {
"id": record.id,
From 5c141cbb26ea54e78869121aba74b2ad9a1c04d0 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 23 Jan 2020 15:14:20 +0100
Subject: [PATCH 051/940] pre-commit run -a
---
shopfloor/services/single_pack_putaway.py | 112 +++++-----------------
1 file changed, 22 insertions(+), 90 deletions(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index cbf6154eb3..b22e779748 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -15,9 +15,7 @@ class SinglePackPutaway(Component):
def scan(self, barcode):
"""Scan a pack barcode"""
- company = (
- self.env.user.company_id
- ) # FIXME add logic to get proper company
+ company = self.env.user.company_id # FIXME add logic to get proper company
# FIXME add logic to get proper warehouse
warehouse = self.env["stock.warehouse"].search([])[0]
picking_type = (
@@ -54,8 +52,7 @@ def scan(self, barcode):
)
if (
existing_operations
- and existing_operations[0].picking_id.picking_type_id
- != picking_type
+ and existing_operations[0].picking_id.picking_type_id != picking_type
):
return self._response(
state="start",
@@ -89,18 +86,14 @@ def scan(self, barcode):
"id": move.product_id.name,
"name": move.product_id.name,
},
- "picking": {
- "id": move.picking_id.id,
- "name": move.picking_id.name,
- },
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
},
state="confirm_start",
message={
"message_type": "warning",
"title": _("Already started"),
"message": _(
- "Operation already running. "
- "Would you like to take it over ?"
+ "Operation already running. " "Would you like to take it over ?"
),
},
)
@@ -154,14 +147,8 @@ def scan(self, barcode):
"id": move.move_line_ids[0].location_dest_id.id,
"name": move.move_line_ids[0].location_dest_id.name,
},
- "product": {
- "id": move.product_id.id,
- "name": move.product_id.name,
- },
- "picking": {
- "id": move.picking_id.id,
- "name": move.picking_id.name,
- },
+ "product": {"id": move.product_id.id, "name": move.product_id.name},
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
},
)
@@ -184,9 +171,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
message={
"message_type": "warning",
"title": _("Restart"),
- "message": _(
- "Restart the operation, someone has canceled it."
- ),
+ "message": _("Restart the operation, someone has canceled it."),
},
)
dest_location = self.env["stock.location"].search(
@@ -214,10 +199,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
"message": _("You cannot place it here"),
},
)
- elif (
- dest_location in allowed_locations
- and dest_location not in zone_locations
- ):
+ elif dest_location in allowed_locations and dest_location not in zone_locations:
if confirmation:
move.location_dest_id = dest_location.id
else:
@@ -236,9 +218,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
message={
"message_type": "info",
"title": _("Start"),
- "message": _(
- "The pack has been moved, you can scan a new pack."
- ),
+ "message": _("The pack has been moved, you can scan a new pack."),
},
)
@@ -259,42 +239,26 @@ def cancel(self, package_level_id):
message={
"message_type": "info",
"title": _("Start"),
- "message": _(
- "The move has been canceled, you can scan a new pack."
- ),
+ "message": _("The move has been canceled, you can scan a new pack."),
},
)
def _validator_cancel(self):
return {
- "package_level_id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- }
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}
}
def _validator_validate(self):
return {
- "package_level_id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "location_barcode": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "location_barcode": {"type": "string", "nullable": False, "required": True},
}
def _validator_return_validate(self):
return self._response_schema()
def _validator_scan(self):
- return {
- "barcode": {"type": "string", "nullable": False, "required": True}
- }
+ return {"barcode": {"type": "string", "nullable": False, "required": True}}
def _validator_return_scan(self):
return self._response_schema(
@@ -303,61 +267,29 @@ def _validator_return_scan(self):
"location_src": {
"type": "dict",
"schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
},
"location_dst": {
"type": "dict",
"schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
},
"product": {
"type": "dict",
"schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
},
"picking": {
"type": "dict",
"schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
},
}
From 05b1de74e6daab7fe5dd378ebbdfa454709c7e7f Mon Sep 17 00:00:00 2001
From: Benoit
Date: Thu, 23 Jan 2020 15:19:35 +0100
Subject: [PATCH 052/940] get picking type properly from menu and profile
---
shopfloor/services/service.py | 9 +++++++++
shopfloor/services/single_pack_putaway.py | 21 +++++++++++++--------
shopfloor/tests/test_single_pack_putaway.py | 5 ++++-
3 files changed, 26 insertions(+), 9 deletions(-)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index f9bfd90518..7b47800d1b 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -15,6 +15,15 @@ class BaseShopfloorService(AbstractComponent):
_actions_collection_name = "shopfloor.action"
_expose_model = None
+ @property
+ def picking_types(self):
+ """
+ Get the current picking type based on the menu and the warehouse of the profile.
+ """
+ return self.work.menu.process_id.picking_type_ids.filtered(
+ lambda p: p.warehouse_id == self.work.profile.warehouse_id
+ )
+
def _get(self, _id):
domain = expression.normalize_domain(self._get_base_search_domain())
domain = expression.AND([domain, [("id", "=", _id)]])
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index b22e779748..38dbea6ce0 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -14,14 +14,19 @@ class SinglePackPutaway(Component):
def scan(self, barcode):
"""Scan a pack barcode"""
-
+ picking_type = self.picking_types
+ if len(picking_type) > 1:
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Configuration error"),
+ "message": _(
+ "Several picking types found for this menu and profile"
+ ),
+ },
+ )
company = self.env.user.company_id # FIXME add logic to get proper company
- # FIXME add logic to get proper warehouse
- warehouse = self.env["stock.warehouse"].search([])[0]
- picking_type = (
- warehouse.int_type_id
- ) # FIXME add logic to get picking type properly
-
# TODO define on what we search (pack name, pack barcode ...)
pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
if not pack:
@@ -93,7 +98,7 @@ def scan(self, barcode):
"message_type": "warning",
"title": _("Already started"),
"message": _(
- "Operation already running. " "Would you like to take it over ?"
+ "Operation already running. Would you like to take it over ?"
),
},
)
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 7d2057b370..7e72d52939 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -33,7 +33,10 @@ def setUp(self, *args, **kwargs):
"location_out_id": out_location.id,
}
)
- with self.work_on_services() as work:
+ menu = self.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck")
+ profile = self.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ profile.warehouse_id.int_type_id.process_id = menu.process_id.id
+ with self.work_on_services(menu=menu, profile=profile) as work:
self.service = work.component(usage="single_pack_putaway")
def test_scan_pack(self):
From 6e0b780314ebd00e182d8ff26420d55f008cac63 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 23 Jan 2020 16:28:24 +0100
Subject: [PATCH 053/940] Use a common Action Component for pack transfer
'validate'
The Action Copmonent contains all the support methods, used in the
workflow for the "validate" method in the Service Component.
The validate method is currently the same in the Service Component for
pack transfer and put-away, but it can make sense if we consider it
deals mainly with the responses to do for the client application: if we
want to, for instance, add a step for one process but not the other, we
want to change this is the Service Component, reusing the methods of the
Actions Component.
---
shopfloor/actions/__init__.py | 5 +-
shopfloor/actions/base_action.py | 4 +-
shopfloor/actions/pack_transfer_validate.py | 39 +++++++
shopfloor/actions/stock_package_level.py | 10 --
shopfloor/services/service.py | 13 +--
shopfloor/services/single_pack_putaway.py | 123 +++++++++++---------
shopfloor/services/single_pack_transfer.py | 81 ++++++++++++-
7 files changed, 191 insertions(+), 84 deletions(-)
create mode 100644 shopfloor/actions/pack_transfer_validate.py
delete mode 100644 shopfloor/actions/stock_package_level.py
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index b9b5c7e551..af4a9151a5 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -7,10 +7,9 @@
* Public methods of a Service Components are exposed in the REST API,
Action Components are never exposed
-* Action Components will generally have an existing Odoo model name
An Action component can be get from Service or Action Components using
-``self.actions_for(model_name)``.
+``self.actions_for(usage)``.
The goal of the Action Components is to share common actions
and processes between Services, avoid having too much logic in
@@ -18,4 +17,4 @@
"""
from . import base_action
-from . import stock_package_level
+from . import pack_transfer_validate
diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py
index e7d5bbda5b..e026e1c1e1 100644
--- a/shopfloor/actions/base_action.py
+++ b/shopfloor/actions/base_action.py
@@ -6,5 +6,5 @@ class ShopFloorProcessAction(AbstractComponent):
_collection = "shopfloor.action"
_usage = "actions"
- def actions_for(self, model_name):
- return self.component(usage="actions", model_name=model_name)
+ def actions_for(self, usage):
+ return self.component(usage=usage)
diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py
new file mode 100644
index 0000000000..bbfe013aca
--- /dev/null
+++ b/shopfloor/actions/pack_transfer_validate.py
@@ -0,0 +1,39 @@
+from odoo.addons.component.core import Component
+
+
+class PackTransferValidateAction(Component):
+ _name = "shopfloor.pack.transfer.validate.action"
+ _inherit = "shopfloor.process.action"
+ _usage = "pack.transfer.validate"
+
+ def location_from_scan(self, barcode):
+ return self.env["stock.location"].search([("barcode", "=", barcode)])
+
+ def is_move_state_valid(self, move):
+ return move.state != "cancel"
+
+ def is_dest_location_valid(self, move, scanned_location):
+ """Forbid a dest location to be used"""
+ allowed_locations = self.env["stock.location"].search(
+ [
+ (
+ "id",
+ "child_of",
+ move.picking_id.picking_type_id.default_location_dest_id.id,
+ )
+ ]
+ )
+ return scanned_location in allowed_locations
+
+ def is_dest_location_to_confirm(self, move, scanned_location):
+ """Destination that could be used but need confirmation"""
+ move_dest_location = move.move_line_ids[0].location_dest_id
+ zone_locations = self.env["stock.location"].search(
+ [("id", "child_of", move_dest_location.id)]
+ )
+ return scanned_location in zone_locations
+
+ def set_destination_and_done(self, move, scanned_location):
+
+ move.move_line_ids[0].location_dest_id = scanned_location.id
+ move._action_done()
diff --git a/shopfloor/actions/stock_package_level.py b/shopfloor/actions/stock_package_level.py
deleted file mode 100644
index dd48a12da6..0000000000
--- a/shopfloor/actions/stock_package_level.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from odoo.addons.component.core import Component
-
-
-class StockPackageLevelAction(Component):
- _name = "shopfloor.stock.package_level.action"
- _inherit = "shopfloor.process.action"
- _apply_on = "stock.package_level"
-
- def hello(self):
- return "world"
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 7b47800d1b..0b85c49958 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -137,8 +137,8 @@ def _get_openapi_default_parameters(self):
def actions_collection(self):
return _PseudoCollection(self._actions_collection_name, self.env)
- def actions_for(self, model_name):
- """Return an Action Component for the model
+ def actions_for(self, usage):
+ """Return an Action Component for a usage
Action Components are the components supporting the business logic of
the processes, so we can limit the code in Services to the minimum and
@@ -148,13 +148,10 @@ def actions_for(self, model_name):
kwargs = {
attr_name: getattr(self.work, attr_name)
for attr_name in self.work._propagate_kwargs
- if attr_name
- not in ("collection", "model_name", "components_registry", "model_name")
+ if attr_name not in ("collection", "components_registry")
}
- work = WorkContext(
- model_name=model_name, collection=self.actions_collection, **kwargs
- )
- return work.component(usage="actions")
+ work = WorkContext(collection=self.actions_collection, **kwargs)
+ return work.component(usage=usage)
def _is_public_api_method(self, method_name):
# do not "hide" the "actions_for" method as internal since, we'll use
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 38dbea6ce0..f0c28df1b8 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -157,67 +157,47 @@ def scan(self, barcode):
},
)
- def validate(self, package_level_id, location_barcode, confirmation=False):
- """Validate the transfer"""
- package = self.env["stock.package_level"].browse(package_level_id)
- if not package.exists():
- return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Start again"),
- "message": _("This operation does not exist anymore."),
- },
- )
- move = package.move_line_ids[0].move_id
- if move.state == "cancel":
- return self._response(
- state="start",
- message={
- "message_type": "warning",
- "title": _("Restart"),
- "message": _("Restart the operation, someone has canceled it."),
- },
- )
- dest_location = self.env["stock.location"].search(
- [("barcode", "=", location_barcode)]
+ def _response_for_package_not_found(self):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Start again"),
+ "message": _("This operation does not exist anymore."),
+ },
)
- move_dest_location = move.move_line_ids[0].location_dest_id
- allowed_locations = self.env["stock.location"].search(
- [
- (
- "id",
- "child_of",
- move.picking_id.picking_type_id.default_location_dest_id.id,
- )
- ]
+
+ def _response_for_move_canceled(self):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "warning",
+ "title": _("Restart"),
+ "message": _("Restart the operation, someone has canceled it."),
+ },
)
- zone_locations = self.env["stock.location"].search(
- [("id", "child_of", move_dest_location.id)]
+
+ def _response_for_forbidden_location(self):
+ return self._response(
+ state="scan_location",
+ message={
+ "message_type": "error",
+ "title": _("Forbidden"),
+ "message": _("You cannot place it here"),
+ },
)
- if dest_location not in allowed_locations:
- return self._response(
- state="scan_location",
- message={
- "message_type": "error",
- "title": _("Forbidden"),
- "message": _("You cannot place it here"),
- },
- )
- elif dest_location in allowed_locations and dest_location not in zone_locations:
- if confirmation:
- move.location_dest_id = dest_location.id
- else:
- return self._response(
- state="confirm_location",
- message={
- "message_type": "warning",
- "title": _("Confirm"),
- "message": _("Are you sure?"),
- },
- )
- move.move_line_ids[0].location_dest_id = dest_location.id
- move._action_done()
+
+ def _response_for_location_need_confirm(self):
+ return self._response(
+ state="confirm_location",
+ message={
+ "message_type": "warning",
+ "title": _("Confirm"),
+ "message": _("Are you sure?"),
+ },
+ )
+
+ def _response_for_validate_success(self):
return self._response(
state="start",
message={
@@ -227,6 +207,33 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
},
)
+ def validate(self, package_level_id, location_barcode, confirmation=False):
+ """Validate the transfer"""
+ pack_transfer = self.actions_for("pack.transfer.validate")
+
+ package = self.env["stock.package_level"].browse(package_level_id)
+ if not package.exists():
+ return self._response_for_package_not_found()
+
+ move = package.move_line_ids[0].move_id
+ if not pack_transfer.is_move_state_valid(move):
+ return self._response_for_move_canceled()
+
+ scanned_location = pack_transfer.location_from_scan(location_barcode)
+ if not pack_transfer.is_dest_location_valid(move, scanned_location):
+ return self._response_for_forbidden_location()
+
+ if not pack_transfer.is_dest_location_to_confirm(move, scanned_location):
+ if confirmation:
+ # keep the move in sync otherwise we would have a move line outside
+ # the dest location of the move
+ move.location_dest_id = scanned_location.id
+ else:
+ return self._response_for_location_need_confirm()
+
+ pack_transfer.set_destination_and_done()
+ return self._response_for_validate_success()
+
def cancel(self, package_level_id):
package = self.env["stock.package_level"].browse(package_level_id)
if not package.exists():
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index e29fdbf56c..204e9656ec 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -1,3 +1,5 @@
+from odoo import _
+
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -10,13 +12,86 @@ class SinglePackTransfer(Component):
_usage = "single_pack_transfer"
_description = __doc__
- def validate(self, package_level_id, location_name, confirmation=False):
+ def _response_for_package_not_found(self):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Start again"),
+ "message": _("This operation does not exist anymore."),
+ },
+ )
+
+ def _response_for_move_canceled(self):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "warning",
+ "title": _("Restart"),
+ "message": _("Restart the operation, someone has canceled it."),
+ },
+ )
+
+ def _response_for_forbidden_location(self):
+ return self._response(
+ state="scan_location",
+ message={
+ "message_type": "error",
+ "title": _("Forbidden"),
+ "message": _("You cannot place it here"),
+ },
+ )
+
+ def _response_for_location_need_confirm(self):
+ return self._response(
+ state="confirm_location",
+ message={
+ "message_type": "warning",
+ "title": _("Confirm"),
+ "message": _("Are you sure?"),
+ },
+ )
+
+ def _response_for_validate_success(self):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "info",
+ "title": _("Start"),
+ "message": _("The pack has been moved, you can scan a new pack."),
+ },
+ )
+
+ def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
- return self.actions_for("stock.package_level").hello()
+ pack_transfer = self.actions_for("pack.transfer.validate")
+
+ package = self.env["stock.package_level"].browse(package_level_id)
+ if not package.exists():
+ return self._response_for_package_not_found()
+
+ move = package.move_line_ids[0].move_id
+ if not pack_transfer.is_move_state_valid(move):
+ return self._response_for_move_canceled()
+
+ scanned_location = pack_transfer.location_from_scan(location_barcode)
+ if not pack_transfer.is_dest_location_valid(move, scanned_location):
+ return self._response_for_forbidden_location()
+
+ if not pack_transfer.is_dest_location_to_confirm(move, scanned_location):
+ if confirmation:
+ # keep the move in sync otherwise we would have a move line outside
+ # the dest location of the move
+ move.location_dest_id = scanned_location.id
+ else:
+ return self._response_for_location_need_confirm()
+
+ pack_transfer.set_destination_and_done()
+ return self._response_for_validate_success()
def _validator_validate(self):
return {
"package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
- "location_name": {"type": "string", "nullable": False, "required": True},
+ "location_barcode": {"type": "string", "nullable": False, "required": True},
"confirmation": {"type": "boolean", "required": False},
}
From 66cb05be5cb5ba70f1ec7263b1d1bbe22db8758a Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 23 Jan 2020 16:53:06 +0100
Subject: [PATCH 054/940] fixup! Use a common Action Component for pack
transfer 'validate'
---
shopfloor/services/single_pack_transfer.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 204e9656ec..6aee17f2a4 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -95,3 +95,6 @@ def _validator_validate(self):
"location_barcode": {"type": "string", "nullable": False, "required": True},
"confirmation": {"type": "boolean", "required": False},
}
+
+ def _validator_return_validate(self):
+ return self._response_schema()
From fc9ba6958618b8915f77b0ed2c7cf05ad72c1eb4 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Thu, 23 Jan 2020 18:07:24 +0100
Subject: [PATCH 055/940] add response methods for start action
---
shopfloor/services/single_pack_putaway.py | 216 +++++++++++---------
shopfloor/services/single_pack_transfer.py | 168 ++++++++++++++-
shopfloor/tests/test_single_pack_putaway.py | 4 +-
3 files changed, 285 insertions(+), 103 deletions(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index f0c28df1b8..8647f9089f 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -12,45 +12,124 @@ class SinglePackPutaway(Component):
_usage = "single_pack_putaway"
_description = __doc__
- def scan(self, barcode):
+ def _response_for_several_picking_types(self):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Configuration error"),
+ "message": _("Several picking types found for this menu and profile"),
+ },
+ )
+
+ def _response_for_package_not_found(self, barcode):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Pack not found"),
+ "message": _("The pack %s doesn't exist") % barcode,
+ },
+ )
+
+ def _response_for_forbidden_package(self, barcode, picking_type):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Cannot proceed"),
+ "message": _("pack %s is not in %s location")
+ % (barcode, picking_type.default_location_src_id.name),
+ },
+ )
+
+ def _response_for_forbidden_start(self, existing_operations):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Cannot proceed"),
+ "message": _(
+ "An operation exists in %s %s. "
+ "You cannot process it with this shopfloor process."
+ )
+ % (
+ existing_operations[0].picking_id.picking_type_id.name,
+ existing_operations[0].picking_id.name,
+ ),
+ },
+ )
+
+ def _response_for_start_to_confirm(self, existing_operations, pack):
+ move = existing_operations.move_id
+ return self._response(
+ data={
+ "id": existing_operations[0].package_level_id.id,
+ "location_src": {
+ "id": pack.location_id.id,
+ "name": pack.location_id.name,
+ },
+ "location_dst": {
+ "id": existing_operations[0].location_dest_id.id,
+ "name": existing_operations[0].location_dest_id.name,
+ },
+ "product": {"id": move.product_id.name, "name": move.product_id.name},
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ },
+ state="confirm_start",
+ message={
+ "message_type": "warning",
+ "title": _("Already started"),
+ "message": _(
+ "Operation already running. Would you like to take it over ?"
+ ),
+ },
+ )
+
+ def _response_for_start_success(self, move, pack):
+ return self._response(
+ state="scan_location",
+ message={
+ "message_type": "info",
+ "title": _("Start"),
+ "message": _(
+ "The move is ready, you can scan the destination location."
+ ),
+ },
+ data={
+ "id": move.move_line_ids[0].package_level_id.id,
+ "location_src": {
+ "id": pack.location_id.id,
+ "name": pack.location_id.name,
+ },
+ "location_dst": {
+ "id": move.move_line_ids[0].location_dest_id.id,
+ "name": move.move_line_ids[0].location_dest_id.name,
+ },
+ "product": {"id": move.product_id.id, "name": move.product_id.name},
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ },
+ )
+
+ def start(self, barcode):
"""Scan a pack barcode"""
+
picking_type = self.picking_types
if len(picking_type) > 1:
- return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Configuration error"),
- "message": _(
- "Several picking types found for this menu and profile"
- ),
- },
- )
+ return self._response_from_several_picking_types()
company = self.env.user.company_id # FIXME add logic to get proper company
# TODO define on what we search (pack name, pack barcode ...)
+
pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
if not pack:
- return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Pack not found"),
- "message": _("The pack %s doesn't exist") % barcode,
- },
- )
+ return self._response_for_package_not_found(barcode)
+
allowed_locations = self.env["stock.location"].search(
[("id", "child_of", picking_type.default_location_src_id.id)]
)
if pack.location_id not in allowed_locations:
- return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Cannot proceed"),
- "message": _("pack %s is not in %s location")
- % (barcode, picking_type.default_location_src_id.name),
- },
- )
+ return self._response_for_forbidden_package(barcode, picking_type)
+
quantity = pack.quant_ids[0].quantity
existing_operations = self.env["stock.move.line"].search(
[("qty_done", "=", quantity), ("package_id", "=", pack.id)]
@@ -59,49 +138,9 @@ def scan(self, barcode):
existing_operations
and existing_operations[0].picking_id.picking_type_id != picking_type
):
- return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Cannot proceed"),
- "message": _(
- "An operation exists in %s %s. "
- "You cannot process it with this shopfloor process."
- )
- % (
- existing_operations[0].picking_id.picking_type_id.name,
- existing_operations[0].picking_id.name,
- ),
- },
- )
+ return self._response_for_forbidden_start(existing_operations)
elif existing_operations:
- move = existing_operations.move_id
- return self._response(
- data={
- "id": move.move_line_ids[0].package_level_id.id,
- "location_src": {
- "id": pack.location_id.id,
- "name": pack.location_id.name,
- },
- "location_dst": {
- "id": move.move_line_ids[0].location_dest_id.id,
- "name": move.move_line_ids[0].location_dest_id.name,
- },
- "product": {
- "id": move.product_id.name,
- "name": move.product_id.name,
- },
- "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
- },
- state="confirm_start",
- message={
- "message_type": "warning",
- "title": _("Already started"),
- "message": _(
- "Operation already running. Would you like to take it over ?"
- ),
- },
- )
+ return self._response_for_start_to_confirm(existing_operations, pack)
product = pack.quant_ids[
0
].product_id # FIXME we consider only one product per pack
@@ -130,34 +169,13 @@ def scan(self, barcode):
"is_done": True,
"location_id": pack.location_id.id,
"location_dest_id": location_dest_id,
+ "picking_id": move.picking_id.id,
}
)
- move.picking_id.action_assign()
- return self._response(
- state="scan_location",
- message={
- "message_type": "info",
- "title": _("Start"),
- "message": _(
- "The move is ready, you can scan the destination location."
- ),
- },
- data={
- "id": move.move_line_ids[0].package_level_id.id,
- "location_src": {
- "id": pack.location_id.id,
- "name": pack.location_id.name,
- },
- "location_dst": {
- "id": move.move_line_ids[0].location_dest_id.id,
- "name": move.move_line_ids[0].location_dest_id.name,
- },
- "product": {"id": move.product_id.id, "name": move.product_id.name},
- "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
- },
- )
+ move._action_assign()
+ return self._response_for_start_success(move, pack)
- def _response_for_package_not_found(self):
+ def _response_for_package_level_not_found(self):
return self._response(
state="start",
message={
@@ -213,7 +231,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
package = self.env["stock.package_level"].browse(package_level_id)
if not package.exists():
- return self._response_for_package_not_found()
+ return self._response_for_package_level_not_found()
move = package.move_line_ids[0].move_id
if not pack_transfer.is_move_state_valid(move):
@@ -231,7 +249,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
else:
return self._response_for_location_need_confirm()
- pack_transfer.set_destination_and_done()
+ pack_transfer.set_destination_and_done(move, scanned_location)
return self._response_for_validate_success()
def cancel(self, package_level_id):
@@ -269,10 +287,10 @@ def _validator_validate(self):
def _validator_return_validate(self):
return self._response_schema()
- def _validator_scan(self):
+ def _validator_start(self):
return {"barcode": {"type": "string", "nullable": False, "required": True}}
- def _validator_return_scan(self):
+ def _validator_return_start(self):
return self._response_schema(
{
"id": {"coerce": to_int, "required": True, "type": "integer"},
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 6aee17f2a4..dbf0784e5e 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -12,7 +12,171 @@ class SinglePackTransfer(Component):
_usage = "single_pack_transfer"
_description = __doc__
- def _response_for_package_not_found(self):
+ def _response_for_empty_location(self, location):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Scan package"),
+ "message": _("Location %s doesn’t contain any PACK." % location.name),
+ },
+ )
+
+ def _response_for_several_packages(self, location):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Scan package"),
+ "message": _(
+ "Several PACKs found in %s, please scan one PACK." % location.name
+ ),
+ },
+ )
+
+ def _response_for_package_not_found(self, barcode):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Pack not found"),
+ "message": _("The pack %s doesn't exist") % barcode,
+ },
+ )
+
+ def _response_for_operation_not_found(self):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Not found"),
+ "message": _(
+ "No pending operation exists for this PACK you cannot process it."
+ ),
+ },
+ )
+
+ def _response_for_start_to_confirm(self, existing_operations, pack):
+ move = existing_operations.move_id
+ return self._response(
+ data={
+ "id": existing_operations[0].package_level_id.id,
+ "location_src": {
+ "id": pack.location_id.id,
+ "name": pack.location_id.name,
+ },
+ "location_dst": {
+ "id": existing_operations[0].location_dest_id.id,
+ "name": existing_operations[0].location_dest_id.name,
+ },
+ "product": {"id": move.product_id.name, "name": move.product_id.name},
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ },
+ state="confirm_start",
+ message={
+ "message_type": "warning",
+ "title": _("Already started"),
+ "message": _(
+ "Operation already running. Would you like to take it over ?"
+ ),
+ },
+ )
+
+ def _response_for_start_success(self, move, pack):
+ return self._response(
+ state="scan_location",
+ message={
+ "message_type": "info",
+ "title": _("Start"),
+ "message": _(
+ "The move is ready, you can scan the destination location."
+ ),
+ },
+ data={
+ "id": move.move_line_ids[0].package_level_id.id,
+ "location_src": {
+ "id": pack.location_id.id,
+ "name": pack.location_id.name,
+ },
+ "location_dst": {
+ "id": move.move_line_ids[0].location_dest_id.id,
+ "name": move.move_line_ids[0].location_dest_id.name,
+ },
+ "product": {"id": move.product_id.id, "name": move.product_id.name},
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ },
+ )
+
+ def start(self, barcode):
+ location = self.env["stock.location"].search([("barcode", "=", barcode)])
+ pack = self.env["stock.quant.package"]
+ if location:
+ pack = self.env["stock.quant.package"].search(
+ [("location_id", "=", location.id)]
+ )
+ if not pack:
+ return self._response_for_empty_location(location)
+ if len(pack) > 1:
+ return self._response_for_several_packages(self, location)
+ if not pack:
+ pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
+ if not pack:
+ return self._response_for_package_not_found(barcode)
+
+ existing_operations = self.env["stock.move.line"].search(
+ [
+ ("package_id", "=", pack.id),
+ ("picking_id.picking_type_id", "in", self.picking_types.ids),
+ ]
+ )
+ if not existing_operations:
+ return self._response_for_operation_not_found()
+ move = existing_operations.move_id
+ if existing_operations[0].package_level_id.is_done:
+ return self._response_for_start_to_confirm(existing_operations, pack)
+
+ existing_operations[0].package_level_id.is_done = True
+ return self._response_for_start_success(move, pack)
+
+ def _validator_start(self):
+ return {"barcode": {"type": "string", "nullable": False, "required": True}}
+
+ def _validator_return_start(self):
+ return self._response_schema(
+ {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "location_src": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "location_dst": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "product": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ }
+ )
+
+ def _response_for_package_level_not_found(self):
return self._response(
state="start",
message={
@@ -68,7 +232,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
package = self.env["stock.package_level"].browse(package_level_id)
if not package.exists():
- return self._response_for_package_not_found()
+ return self._response_for_package_level_not_found()
move = package.move_line_ids[0].move_id
if not pack_transfer.is_move_state_valid(move):
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 7e72d52939..5347ba99c2 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -39,10 +39,10 @@ def setUp(self, *args, **kwargs):
with self.work_on_services(menu=menu, profile=profile) as work:
self.service = work.component(usage="single_pack_putaway")
- def test_scan_pack(self):
+ def test_single_pack_putaway(self):
barcode = self.packA.name
params = {"barcode": barcode}
- response = self.service.dispatch("scan", params=params)
+ response = self.service.dispatch("start", params=params)
package_level = self.env["stock.package_level"].browse(response["data"]["id"])
move_id = package_level.move_line_ids[0].move_id.id
location_dest = self.env["stock.location"].browse(
From 9b1106b619b7bb32ea7c6870d844d3214f4156b6 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Fri, 24 Jan 2020 09:25:43 +0100
Subject: [PATCH 056/940] add name in start return
---
shopfloor/services/single_pack_putaway.py | 3 +++
shopfloor/services/single_pack_transfer.py | 3 +++
2 files changed, 6 insertions(+)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 8647f9089f..a4c5851cd0 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -65,6 +65,7 @@ def _response_for_start_to_confirm(self, existing_operations, pack):
return self._response(
data={
"id": existing_operations[0].package_level_id.id,
+ "name": pack.name,
"location_src": {
"id": pack.location_id.id,
"name": pack.location_id.name,
@@ -98,6 +99,7 @@ def _response_for_start_success(self, move, pack):
},
data={
"id": move.move_line_ids[0].package_level_id.id,
+ "name": pack.name,
"location_src": {
"id": pack.location_id.id,
"name": pack.location_id.name,
@@ -294,6 +296,7 @@ def _validator_return_start(self):
return self._response_schema(
{
"id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
"location_src": {
"type": "dict",
"schema": {
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index dbf0784e5e..26abb6e18d 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -61,6 +61,7 @@ def _response_for_start_to_confirm(self, existing_operations, pack):
return self._response(
data={
"id": existing_operations[0].package_level_id.id,
+ "name": pack.name,
"location_src": {
"id": pack.location_id.id,
"name": pack.location_id.name,
@@ -94,6 +95,7 @@ def _response_for_start_success(self, move, pack):
},
data={
"id": move.move_line_ids[0].package_level_id.id,
+ "name": pack.name,
"location_src": {
"id": pack.location_id.id,
"name": pack.location_id.name,
@@ -145,6 +147,7 @@ def _validator_return_start(self):
return self._response_schema(
{
"id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
"location_src": {
"type": "dict",
"schema": {
From 68eb4e51e414a2823cc63141cbb1470abf9ea48d Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 24 Jan 2020 12:02:30 +0100
Subject: [PATCH 057/940] Add a test for validation of pack put-away
Also, the test case is a subclass of SavepointCase, so take benefit
of this by creating the setup in setUpClass.
---
shopfloor/actions/pack_transfer_validate.py | 9 +++
shopfloor/services/single_pack_putaway.py | 2 +-
shopfloor/tests/test_single_pack_putaway.py | 87 +++++++++++++++++----
3 files changed, 82 insertions(+), 16 deletions(-)
diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py
index bbfe013aca..8caef8bfc1 100644
--- a/shopfloor/actions/pack_transfer_validate.py
+++ b/shopfloor/actions/pack_transfer_validate.py
@@ -2,6 +2,15 @@
class PackTransferValidateAction(Component):
+ """Pack Transfer shared business logic
+
+
+ This component is shared by the "validate" action of the processes:
+
+ * single_pack_putaway
+ * single_pack_transfer
+ """
+
_name = "shopfloor.pack.transfer.validate.action"
_inherit = "shopfloor.process.action"
_usage = "pack.transfer.validate"
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index a4c5851cd0..15483cc152 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -243,7 +243,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if not pack_transfer.is_dest_location_valid(move, scanned_location):
return self._response_for_forbidden_location()
- if not pack_transfer.is_dest_location_to_confirm(move, scanned_location):
+ if pack_transfer.is_dest_location_to_confirm(move, scanned_location):
if confirmation:
# keep the move in sync otherwise we would have a move line outside
# the dest location of the move
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 5347ba99c2..8bb1181ca7 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -1,42 +1,52 @@
+from odoo.tests.common import Form
+
from .common import CommonCase
class PutawayCase(CommonCase):
- def setUp(self, *args, **kwargs):
- super(PutawayCase, self).setUp(*args, **kwargs)
- stock_location = self.env.ref("stock.stock_location_stock")
- out_location = self.env["stock.location"].search(
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ stock_location = cls.env.ref("stock.stock_location_stock")
+ cls.stock_location = stock_location
+ out_location = cls.env["stock.location"].search(
[
("location_id", "=", stock_location.id),
("barcode", "!=", False),
("usage", "=", "internal"),
]
)[0]
- self.productA = self.env["product.product"].create(
+ cls.productA = cls.env["product.product"].create(
{"name": "Product A", "type": "product"}
)
- self.packA = self.env["stock.quant.package"].create(
+ cls.packA = cls.env["stock.quant.package"].create(
{"location_id": stock_location.id}
)
- self.quantA = self.env["stock.quant"].create(
+ cls.quantA = cls.env["stock.quant"].create(
{
- "product_id": self.productA.id,
+ "product_id": cls.productA.id,
"location_id": stock_location.id,
"quantity": 1,
- "package_id": self.packA.id,
+ "package_id": cls.packA.id,
}
)
- self.env["stock.putaway.rule"].create(
+ cls.env["stock.putaway.rule"].create(
{
- "product_id": self.productA.id,
+ "product_id": cls.productA.id,
"location_in_id": stock_location.id,
"location_out_id": out_location.id,
}
)
- menu = self.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck")
- profile = self.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
- profile.warehouse_id.int_type_id.process_id = menu.process_id.id
- with self.work_on_services(menu=menu, profile=profile) as work:
+ cls.shelf1 = cls.env.ref("stock.stock_location_components")
+ cls.shelf2 = cls.env.ref("stock.stock_location_14")
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck")
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.wh.int_type_id.process_id = cls.menu.process_id.id
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="single_pack_putaway")
def test_single_pack_putaway(self):
@@ -70,3 +80,50 @@ def test_single_pack_putaway(self):
self.assertEquals(move.state, "done")
self.assertEquals(self.quantA.quantity, 0)
self.assertEquals(new_loc_quant.quantity, move.product_uom_qty)
+
+ def test_validate(self):
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ picking_form = Form(self.env["stock.picking"])
+ picking_form.picking_type_id = self.wh.int_type_id
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = self.productA
+ move.product_uom_qty = 1
+ picking = picking_form.save()
+ picking.action_confirm()
+ picking.action_assign()
+ package_level = picking.move_line_ids.package_level_id
+ self.assertEqual(package_level.package_id, self.packA)
+ # at this point, the package level is already set to "done", by the
+ # "start" method of the pack transfer putaway
+ package_level.is_done = True
+
+ # now, call the service to proceed with validation of the
+ # movement
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ },
+ )
+ self.assertDictEqual(
+ response,
+ {
+ "message": {
+ "message": "The pack has been moved," " you can scan a new pack.",
+ "message_type": "info",
+ "title": "Start",
+ },
+ "state": "start",
+ },
+ )
+
+ self.assertRecordValues(
+ package_level.move_line_ids,
+ [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}],
+ )
+ self.assertRecordValues(
+ package_level.move_line_ids.move_id,
+ [{"location_dest_id": self.stock_location.id, "state": "done"}],
+ )
From 65249bdf83e6d1ea29cd2b5182fb0096b0176573 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 24 Jan 2020 15:00:29 +0100
Subject: [PATCH 058/940] Improve pack putaway tests
---
shopfloor/__manifest__.py | 1 +
shopfloor/actions/pack_transfer_validate.py | 2 +-
shopfloor/demo/stock_picking_type_demo.xml | 35 +++++++++++++
shopfloor/services/single_pack_putaway.py | 19 +++++--
shopfloor/services/single_pack_transfer.py | 2 +-
shopfloor/tests/test_single_pack_putaway.py | 58 ++++++---------------
6 files changed, 71 insertions(+), 46 deletions(-)
create mode 100644 shopfloor/demo/stock_picking_type_demo.xml
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index bfd999f5f0..69f064b2c2 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -24,6 +24,7 @@
"demo": [
"demo/auth_api_key_demo.xml",
"demo/shopfloor_process_demo.xml",
+ "demo/stock_picking_type_demo.xml",
"demo/shopfloor_menu_demo.xml",
"demo/shopfloor_operation_group_demo.xml",
"demo/shopfloor_profile_demo.xml",
diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py
index 8caef8bfc1..1404f10d49 100644
--- a/shopfloor/actions/pack_transfer_validate.py
+++ b/shopfloor/actions/pack_transfer_validate.py
@@ -40,7 +40,7 @@ def is_dest_location_to_confirm(self, move, scanned_location):
zone_locations = self.env["stock.location"].search(
[("id", "child_of", move_dest_location.id)]
)
- return scanned_location in zone_locations
+ return scanned_location not in zone_locations
def set_destination_and_done(self, move, scanned_location):
diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml
new file mode 100644
index 0000000000..c6bb3bae22
--- /dev/null
+++ b/shopfloor/demo/stock_picking_type_demo.xml
@@ -0,0 +1,35 @@
+
+
+
+ Put-Away Reach Truck
+ PART
+
+
+
+
+
+
+
+
+ internal
+
+
+
+
+
+ Single Pallet Transfer
+ SPT
+
+
+
+
+
+
+
+
+ internal
+
+
+
+
+
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 15483cc152..8f4fc93a23 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -12,6 +12,16 @@ class SinglePackPutaway(Component):
_usage = "single_pack_putaway"
_description = __doc__
+ def _response_for_no_picking_type(self):
+ return self._response(
+ state="start",
+ message={
+ "message_type": "error",
+ "title": _("Configuration error"),
+ "message": _("No picking types found for this menu and profile"),
+ },
+ )
+
def _response_for_several_picking_types(self):
return self._response(
state="start",
@@ -118,10 +128,12 @@ def start(self, barcode):
picking_type = self.picking_types
if len(picking_type) > 1:
- return self._response_from_several_picking_types()
- company = self.env.user.company_id # FIXME add logic to get proper company
- # TODO define on what we search (pack name, pack barcode ...)
+ return self._response_for_several_picking_types()
+ elif not picking_type:
+ return self._response_for_no_picking_type()
+ company = self.env.company
+ # TODO define on what we search (pack name, pack barcode ...)
pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
if not pack:
return self._response_for_package_not_found(barcode)
@@ -265,6 +277,7 @@ def cancel(self, package_level_id):
"message": _("This operation does not exist anymore."),
},
)
+ # TODO cancel() does not exist
package.move_ids[0].cancel()
return self._response(
state="start",
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 26abb6e18d..2aea9d7ae7 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -245,7 +245,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if not pack_transfer.is_dest_location_valid(move, scanned_location):
return self._response_for_forbidden_location()
- if not pack_transfer.is_dest_location_to_confirm(move, scanned_location):
+ if pack_transfer.is_dest_location_to_confirm(move, scanned_location):
if confirmation:
# keep the move in sync otherwise we would have a move line outside
# the dest location of the move
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 8bb1181ca7..8986341e59 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -9,13 +9,9 @@ def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
stock_location = cls.env.ref("stock.stock_location_stock")
cls.stock_location = stock_location
- out_location = cls.env["stock.location"].search(
- [
- ("location_id", "=", stock_location.id),
- ("barcode", "!=", False),
- ("usage", "=", "internal"),
- ]
- )[0]
+ cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone")
+ cls.input_location = cls.env.ref("stock.stock_location_company")
+ cls.out_location = cls.env.ref("stock.stock_location_output")
cls.productA = cls.env["product.product"].create(
{"name": "Product A", "type": "product"}
)
@@ -25,24 +21,16 @@ def setUpClass(cls, *args, **kwargs):
cls.quantA = cls.env["stock.quant"].create(
{
"product_id": cls.productA.id,
- "location_id": stock_location.id,
+ "location_id": cls.dispatch_location.id,
"quantity": 1,
"package_id": cls.packA.id,
}
)
- cls.env["stock.putaway.rule"].create(
- {
- "product_id": cls.productA.id,
- "location_in_id": stock_location.id,
- "location_out_id": out_location.id,
- }
- )
cls.shelf1 = cls.env.ref("stock.stock_location_components")
cls.shelf2 = cls.env.ref("stock.stock_location_14")
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck")
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
- cls.wh.int_type_id.process_id = cls.menu.process_id.id
def setUp(self):
super().setUp()
@@ -52,40 +40,28 @@ def setUp(self):
def test_single_pack_putaway(self):
barcode = self.packA.name
params = {"barcode": barcode}
+ # Simulate the client scanning a package's barcode, which
+ # in turns should start the operation in odoo
response = self.service.dispatch("start", params=params)
+ # the response
+
+ # Checks:
package_level = self.env["stock.package_level"].browse(response["data"]["id"])
- move_id = package_level.move_line_ids[0].move_id.id
- location_dest = self.env["stock.location"].browse(
- response["data"]["location_dst"]["id"]
- )
- params = {
- "package_level_id": package_level.id,
- "location_barcode": location_dest.barcode,
- }
- new_loc_quant = self.env["stock.quant"].search(
- [
- ("product_id", "=", self.productA.id),
- ("location_id", "=", location_dest.id),
- ]
+ move_line = package_level.move_line_ids
+ move = move_line.move_id
+
+ self.assertRecordValues(
+ move_line, [{"qty_done": 1.0, "location_dest_id": self.stock_location.id}]
)
- self.assertFalse(new_loc_quant)
- response = self.service.dispatch("validate", params=params)
- new_loc_quant = self.env["stock.quant"].search(
- [
- ("product_id", "=", self.productA.id),
- ("location_id", "=", location_dest.id),
- ]
+ self.assertRecordValues(
+ move, [{"state": "assigned", "location_dest_id": self.stock_location.id}]
)
- move = self.env["stock.move"].browse(move_id)
- self.assertEquals(move.state, "done")
- self.assertEquals(self.quantA.quantity, 0)
- self.assertEquals(new_loc_quant.quantity, move.product_uom_qty)
def test_validate(self):
# setup the picking as we need, like if the move line
# was already started by the first step (start operation)
picking_form = Form(self.env["stock.picking"])
- picking_form.picking_type_id = self.wh.int_type_id
+ picking_form.picking_type_id = self.menu.process_id.picking_type_ids
with picking_form.move_ids_without_package.new() as move:
move.product_id = self.productA
move.product_uom_qty = 1
From 3b5954989106558813ecf951b593c1f3eecab3e6 Mon Sep 17 00:00:00 2001
From: Benoit
Date: Fri, 24 Jan 2020 15:44:16 +0100
Subject: [PATCH 059/940] simplified single pack putaway
---
shopfloor/services/single_pack_putaway.py | 10 ++--------
1 file changed, 2 insertions(+), 8 deletions(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 8f4fc93a23..6e287dc986 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -171,22 +171,16 @@ def start(self, barcode):
}
move = self.env["stock.move"].create(move_vals)
move._action_confirm(merge=False)
- location_dest_id = (
- default_location_dest._get_putaway_strategy(product).id
- or default_location_dest.id
- )
- self.env["stock.package_level"].create(
+ package_level = self.env["stock.package_level"].create(
{
"package_id": pack.id,
"move_ids": [(4, move.id)],
"company_id": company.id,
- "is_done": True,
- "location_id": pack.location_id.id,
- "location_dest_id": location_dest_id,
"picking_id": move.picking_id.id,
}
)
move._action_assign()
+ package_level.is_done = True
return self._response_for_start_success(move, pack)
def _response_for_package_level_not_found(self):
From 7f241fb2222a308ac17a00d29388c606170e3873 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 31 Jan 2020 14:25:57 +0100
Subject: [PATCH 060/940] shopfloor: adapt state names and enpoints
---
shopfloor/services/single_pack_putaway.py | 40 +++++++++++------------
1 file changed, 20 insertions(+), 20 deletions(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 6e287dc986..ef890af2ac 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -14,7 +14,7 @@ class SinglePackPutaway(Component):
def _response_for_no_picking_type(self):
return self._response(
- state="start",
+ state="scan_pack",
message={
"message_type": "error",
"title": _("Configuration error"),
@@ -24,7 +24,7 @@ def _response_for_no_picking_type(self):
def _response_for_several_picking_types(self):
return self._response(
- state="start",
+ state="scan_pack",
message={
"message_type": "error",
"title": _("Configuration error"),
@@ -34,7 +34,7 @@ def _response_for_several_picking_types(self):
def _response_for_package_not_found(self, barcode):
return self._response(
- state="start",
+ state="scan_pack",
message={
"message_type": "error",
"title": _("Pack not found"),
@@ -44,7 +44,7 @@ def _response_for_package_not_found(self, barcode):
def _response_for_forbidden_package(self, barcode, picking_type):
return self._response(
- state="start",
+ state="scan_pack",
message={
"message_type": "error",
"title": _("Cannot proceed"),
@@ -53,9 +53,9 @@ def _response_for_forbidden_package(self, barcode, picking_type):
},
)
- def _response_for_forbidden_start(self, existing_operations):
+ def _response_for_forbidden_scan_pack(self, existing_operations):
return self._response(
- state="start",
+ state="scan_pack",
message={
"message_type": "error",
"title": _("Cannot proceed"),
@@ -70,7 +70,7 @@ def _response_for_forbidden_start(self, existing_operations):
},
)
- def _response_for_start_to_confirm(self, existing_operations, pack):
+ def _response_for_scan_pack_to_confirm(self, existing_operations, pack):
move = existing_operations.move_id
return self._response(
data={
@@ -87,7 +87,7 @@ def _response_for_start_to_confirm(self, existing_operations, pack):
"product": {"id": move.product_id.name, "name": move.product_id.name},
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
},
- state="confirm_start",
+ state="takeover",
message={
"message_type": "warning",
"title": _("Already started"),
@@ -97,7 +97,7 @@ def _response_for_start_to_confirm(self, existing_operations, pack):
},
)
- def _response_for_start_success(self, move, pack):
+ def _response_for_scan_pack_success(self, move, pack):
return self._response(
state="scan_location",
message={
@@ -123,7 +123,7 @@ def _response_for_start_success(self, move, pack):
},
)
- def start(self, barcode):
+ def scan_pack(self, barcode):
"""Scan a pack barcode"""
picking_type = self.picking_types
@@ -152,9 +152,9 @@ def start(self, barcode):
existing_operations
and existing_operations[0].picking_id.picking_type_id != picking_type
):
- return self._response_for_forbidden_start(existing_operations)
+ return self._response_for_forbidden_scan_pack(existing_operations)
elif existing_operations:
- return self._response_for_start_to_confirm(existing_operations, pack)
+ return self._response_for_scan_pack_to_confirm(existing_operations, pack)
product = pack.quant_ids[
0
].product_id # FIXME we consider only one product per pack
@@ -181,11 +181,11 @@ def start(self, barcode):
)
move._action_assign()
package_level.is_done = True
- return self._response_for_start_success(move, pack)
+ return self._response_for_scan_pack_success(move, pack)
def _response_for_package_level_not_found(self):
return self._response(
- state="start",
+ state="scan_pack",
message={
"message_type": "error",
"title": _("Start again"),
@@ -195,7 +195,7 @@ def _response_for_package_level_not_found(self):
def _response_for_move_canceled(self):
return self._response(
- state="start",
+ state="scan_pack",
message={
"message_type": "warning",
"title": _("Restart"),
@@ -225,7 +225,7 @@ def _response_for_location_need_confirm(self):
def _response_for_validate_success(self):
return self._response(
- state="start",
+ state="scan_pack",
message={
"message_type": "info",
"title": _("Start"),
@@ -264,7 +264,7 @@ def cancel(self, package_level_id):
package = self.env["stock.package_level"].browse(package_level_id)
if not package.exists():
return self._response(
- state="start",
+ state="scan_pack",
message={
"message_type": "error",
"title": _("Start again"),
@@ -274,7 +274,7 @@ def cancel(self, package_level_id):
# TODO cancel() does not exist
package.move_ids[0].cancel()
return self._response(
- state="start",
+ state="scan_pack",
message={
"message_type": "info",
"title": _("Start"),
@@ -296,10 +296,10 @@ def _validator_validate(self):
def _validator_return_validate(self):
return self._response_schema()
- def _validator_start(self):
+ def _validator_scan_pack(self):
return {"barcode": {"type": "string", "nullable": False, "required": True}}
- def _validator_return_start(self):
+ def _validator_return_scan_pack(self):
return self._response_schema(
{
"id": {"coerce": to_int, "required": True, "type": "integer"},
From bba2a4113dc75ec3fcd9a1be26c2156339548210 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 31 Jan 2020 15:12:19 +0100
Subject: [PATCH 061/940] shopfloor: message has `body`
---
shopfloor/services/service.py | 2 +-
shopfloor/services/single_pack_putaway.py | 28 ++++++++++-----------
shopfloor/tests/test_single_pack_putaway.py | 2 +-
3 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 0b85c49958..bfed200be3 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -90,7 +90,7 @@ def _response_schema(self, data_schema=None):
"allowed": ["info", "warning", "error"],
},
"title": {"type": "string", "required": False},
- "message": {"type": "string", "required": True},
+ "body": {"type": "string", "required": True},
},
},
}
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index ef890af2ac..22ab2976c2 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -18,7 +18,7 @@ def _response_for_no_picking_type(self):
message={
"message_type": "error",
"title": _("Configuration error"),
- "message": _("No picking types found for this menu and profile"),
+ "body": _("No picking types found for this menu and profile"),
},
)
@@ -28,7 +28,7 @@ def _response_for_several_picking_types(self):
message={
"message_type": "error",
"title": _("Configuration error"),
- "message": _("Several picking types found for this menu and profile"),
+ "body": _("Several picking types found for this menu and profile"),
},
)
@@ -38,7 +38,7 @@ def _response_for_package_not_found(self, barcode):
message={
"message_type": "error",
"title": _("Pack not found"),
- "message": _("The pack %s doesn't exist") % barcode,
+ "body": _("The pack %s doesn't exist") % barcode,
},
)
@@ -48,7 +48,7 @@ def _response_for_forbidden_package(self, barcode, picking_type):
message={
"message_type": "error",
"title": _("Cannot proceed"),
- "message": _("pack %s is not in %s location")
+ "body": _("pack %s is not in %s location")
% (barcode, picking_type.default_location_src_id.name),
},
)
@@ -59,7 +59,7 @@ def _response_for_forbidden_scan_pack(self, existing_operations):
message={
"message_type": "error",
"title": _("Cannot proceed"),
- "message": _(
+ "body": _(
"An operation exists in %s %s. "
"You cannot process it with this shopfloor process."
)
@@ -91,7 +91,7 @@ def _response_for_scan_pack_to_confirm(self, existing_operations, pack):
message={
"message_type": "warning",
"title": _("Already started"),
- "message": _(
+ "body": _(
"Operation already running. Would you like to take it over ?"
),
},
@@ -103,7 +103,7 @@ def _response_for_scan_pack_success(self, move, pack):
message={
"message_type": "info",
"title": _("Start"),
- "message": _(
+ "body": _(
"The move is ready, you can scan the destination location."
),
},
@@ -189,7 +189,7 @@ def _response_for_package_level_not_found(self):
message={
"message_type": "error",
"title": _("Start again"),
- "message": _("This operation does not exist anymore."),
+ "body": _("This operation does not exist anymore."),
},
)
@@ -199,7 +199,7 @@ def _response_for_move_canceled(self):
message={
"message_type": "warning",
"title": _("Restart"),
- "message": _("Restart the operation, someone has canceled it."),
+ "body": _("Restart the operation, someone has canceled it."),
},
)
@@ -209,7 +209,7 @@ def _response_for_forbidden_location(self):
message={
"message_type": "error",
"title": _("Forbidden"),
- "message": _("You cannot place it here"),
+ "body": _("You cannot place it here"),
},
)
@@ -219,7 +219,7 @@ def _response_for_location_need_confirm(self):
message={
"message_type": "warning",
"title": _("Confirm"),
- "message": _("Are you sure?"),
+ "body": _("Are you sure?"),
},
)
@@ -229,7 +229,7 @@ def _response_for_validate_success(self):
message={
"message_type": "info",
"title": _("Start"),
- "message": _("The pack has been moved, you can scan a new pack."),
+ "body": _("The pack has been moved, you can scan a new pack."),
},
)
@@ -268,7 +268,7 @@ def cancel(self, package_level_id):
message={
"message_type": "error",
"title": _("Start again"),
- "message": _("This operation does not exist anymore."),
+ "body": _("This operation does not exist anymore."),
},
)
# TODO cancel() does not exist
@@ -278,7 +278,7 @@ def cancel(self, package_level_id):
message={
"message_type": "info",
"title": _("Start"),
- "message": _("The move has been canceled, you can scan a new pack."),
+ "body": _("The move has been canceled, you can scan a new pack."),
},
)
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 8986341e59..4ef3078e22 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -87,7 +87,7 @@ def test_validate(self):
response,
{
"message": {
- "message": "The pack has been moved," " you can scan a new pack.",
+ "body": "The pack has been moved," " you can scan a new pack.",
"message_type": "info",
"title": "Start",
},
From f6f29e15c024e279d2b51f94ca34c2fc0ea3c0a2 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 4 Feb 2020 16:54:48 +0100
Subject: [PATCH 062/940] Extend tests coverage for single pack put-away
---
shopfloor/actions/base_action.py | 2 +
shopfloor/actions/pack_transfer_validate.py | 1 -
shopfloor/services/single_pack_putaway.py | 93 +++---
shopfloor/tests/test_single_pack_putaway.py | 310 +++++++++++++++++++-
4 files changed, 355 insertions(+), 51 deletions(-)
diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py
index e026e1c1e1..76eac66eba 100644
--- a/shopfloor/actions/base_action.py
+++ b/shopfloor/actions/base_action.py
@@ -2,6 +2,8 @@
class ShopFloorProcessAction(AbstractComponent):
+ """Base Component for actions"""
+
_name = "shopfloor.process.action"
_collection = "shopfloor.action"
_usage = "actions"
diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py
index 1404f10d49..19c5f25ccb 100644
--- a/shopfloor/actions/pack_transfer_validate.py
+++ b/shopfloor/actions/pack_transfer_validate.py
@@ -4,7 +4,6 @@
class PackTransferValidateAction(Component):
"""Pack Transfer shared business logic
-
This component is shared by the "validate" action of the processes:
* single_pack_putaway
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 22ab2976c2..61ee7a2476 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -14,52 +14,52 @@ class SinglePackPutaway(Component):
def _response_for_no_picking_type(self):
return self._response(
- state="scan_pack",
+ state="start",
message={
"message_type": "error",
"title": _("Configuration error"),
- "body": _("No picking types found for this menu and profile"),
+ "message": _("No picking types found for this menu and profile"),
},
)
def _response_for_several_picking_types(self):
return self._response(
- state="scan_pack",
+ state="start",
message={
"message_type": "error",
"title": _("Configuration error"),
- "body": _("Several picking types found for this menu and profile"),
+ "message": _("Several picking types found for this menu and profile"),
},
)
def _response_for_package_not_found(self, barcode):
return self._response(
- state="scan_pack",
+ state="start",
message={
"message_type": "error",
"title": _("Pack not found"),
- "body": _("The pack %s doesn't exist") % barcode,
+ "message": _("The pack %s doesn't exist") % barcode,
},
)
def _response_for_forbidden_package(self, barcode, picking_type):
return self._response(
- state="scan_pack",
+ state="start",
message={
"message_type": "error",
"title": _("Cannot proceed"),
- "body": _("pack %s is not in %s location")
+ "message": _("pack %s is not in %s location")
% (barcode, picking_type.default_location_src_id.name),
},
)
- def _response_for_forbidden_scan_pack(self, existing_operations):
+ def _response_for_forbidden_start(self, existing_operations):
return self._response(
- state="scan_pack",
+ state="start",
message={
"message_type": "error",
"title": _("Cannot proceed"),
- "body": _(
+ "message": _(
"An operation exists in %s %s. "
"You cannot process it with this shopfloor process."
)
@@ -70,7 +70,7 @@ def _response_for_forbidden_scan_pack(self, existing_operations):
},
)
- def _response_for_scan_pack_to_confirm(self, existing_operations, pack):
+ def _response_for_start_to_confirm(self, existing_operations, pack):
move = existing_operations.move_id
return self._response(
data={
@@ -87,23 +87,23 @@ def _response_for_scan_pack_to_confirm(self, existing_operations, pack):
"product": {"id": move.product_id.name, "name": move.product_id.name},
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
},
- state="takeover",
+ state="confirm_start",
message={
"message_type": "warning",
"title": _("Already started"),
- "body": _(
+ "message": _(
"Operation already running. Would you like to take it over ?"
),
},
)
- def _response_for_scan_pack_success(self, move, pack):
+ def _response_for_start_success(self, move, pack):
return self._response(
state="scan_location",
message={
"message_type": "info",
"title": _("Start"),
- "body": _(
+ "message": _(
"The move is ready, you can scan the destination location."
),
},
@@ -123,7 +123,7 @@ def _response_for_scan_pack_success(self, move, pack):
},
)
- def scan_pack(self, barcode):
+ def start(self, barcode):
"""Scan a pack barcode"""
picking_type = self.picking_types
@@ -134,6 +134,7 @@ def scan_pack(self, barcode):
company = self.env.company
# TODO define on what we search (pack name, pack barcode ...)
+ # we can create a component with the search methods (easy to override)
pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
if not pack:
return self._response_for_package_not_found(barcode)
@@ -152,9 +153,9 @@ def scan_pack(self, barcode):
existing_operations
and existing_operations[0].picking_id.picking_type_id != picking_type
):
- return self._response_for_forbidden_scan_pack(existing_operations)
+ return self._response_for_forbidden_start(existing_operations)
elif existing_operations:
- return self._response_for_scan_pack_to_confirm(existing_operations, pack)
+ return self._response_for_start_to_confirm(existing_operations, pack)
product = pack.quant_ids[
0
].product_id # FIXME we consider only one product per pack
@@ -181,25 +182,35 @@ def scan_pack(self, barcode):
)
move._action_assign()
package_level.is_done = True
- return self._response_for_scan_pack_success(move, pack)
+ return self._response_for_start_success(move, pack)
def _response_for_package_level_not_found(self):
return self._response(
- state="scan_pack",
+ state="start",
message={
"message_type": "error",
"title": _("Start again"),
- "body": _("This operation does not exist anymore."),
+ "message": _("This operation does not exist anymore."),
},
)
def _response_for_move_canceled(self):
return self._response(
- state="scan_pack",
+ state="start",
message={
"message_type": "warning",
"title": _("Restart"),
- "body": _("Restart the operation, someone has canceled it."),
+ "message": _("Restart the operation, someone has canceled it."),
+ },
+ )
+
+ def _response_for_location_not_found(self):
+ return self._response(
+ state="scan_location",
+ message={
+ "message_type": "error",
+ "title": _("Scan"),
+ "message": _("No location found for this barcode."),
},
)
@@ -209,7 +220,7 @@ def _response_for_forbidden_location(self):
message={
"message_type": "error",
"title": _("Forbidden"),
- "body": _("You cannot place it here"),
+ "message": _("You cannot place it here"),
},
)
@@ -219,17 +230,17 @@ def _response_for_location_need_confirm(self):
message={
"message_type": "warning",
"title": _("Confirm"),
- "body": _("Are you sure?"),
+ "message": _("Are you sure?"),
},
)
def _response_for_validate_success(self):
return self._response(
- state="scan_pack",
+ state="start",
message={
"message_type": "info",
"title": _("Start"),
- "body": _("The pack has been moved, you can scan a new pack."),
+ "message": _("The pack has been moved, you can scan a new pack."),
},
)
@@ -245,7 +256,10 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if not pack_transfer.is_move_state_valid(move):
return self._response_for_move_canceled()
+ # TODO move scan search in a component for this?
scanned_location = pack_transfer.location_from_scan(location_barcode)
+ if not scanned_location:
+ return self._response_for_location_not_found()
if not pack_transfer.is_dest_location_valid(move, scanned_location):
return self._response_for_forbidden_location()
@@ -263,22 +277,25 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
def cancel(self, package_level_id):
package = self.env["stock.package_level"].browse(package_level_id)
if not package.exists():
+ return self._response_for_package_level_not_found()
+ # package.move_ids may be empty, it seems
+ move = package.move_line_ids.move_id
+ if move.state == "done":
return self._response(
- state="scan_pack",
+ state="start",
message={
- "message_type": "error",
- "title": _("Start again"),
- "body": _("This operation does not exist anymore."),
+ "message_type": "info",
+ "title": _("Start"),
+ "message": _("Move already processed."),
},
)
- # TODO cancel() does not exist
- package.move_ids[0].cancel()
+ package.move_line_ids.move_id._action_cancel()
return self._response(
- state="scan_pack",
+ state="start",
message={
"message_type": "info",
"title": _("Start"),
- "body": _("The move has been canceled, you can scan a new pack."),
+ "message": _("The move has been canceled, you can scan a new pack."),
},
)
@@ -296,10 +313,10 @@ def _validator_validate(self):
def _validator_return_validate(self):
return self._response_schema()
- def _validator_scan_pack(self):
+ def _validator_start(self):
return {"barcode": {"type": "string", "nullable": False, "required": True}}
- def _validator_return_scan_pack(self):
+ def _validator_return_start(self):
return self._response_schema(
{
"id": {"coerce": to_int, "required": True, "type": "integer"},
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 4ef3078e22..a5997feac1 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -10,14 +10,23 @@ def setUpClass(cls, *args, **kwargs):
stock_location = cls.env.ref("stock.stock_location_stock")
cls.stock_location = stock_location
cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone")
+ cls.dispatch_location.barcode = "DISPATCH"
cls.input_location = cls.env.ref("stock.stock_location_company")
- cls.out_location = cls.env.ref("stock.stock_location_output")
+ cls.shelf1 = cls.env.ref("stock.stock_location_components")
+ cls.shelf2 = cls.env.ref("stock.stock_location_14")
cls.productA = cls.env["product.product"].create(
{"name": "Product A", "type": "product"}
)
cls.packA = cls.env["stock.quant.package"].create(
{"location_id": stock_location.id}
)
+ cls.env["stock.putaway.rule"].create(
+ {
+ "product_id": cls.productA.id,
+ "location_in_id": cls.stock_location.id,
+ "location_out_id": cls.shelf1.id,
+ }
+ )
cls.quantA = cls.env["stock.quant"].create(
{
"product_id": cls.productA.id,
@@ -26,8 +35,6 @@ def setUpClass(cls, *args, **kwargs):
"package_id": cls.packA.id,
}
)
- cls.shelf1 = cls.env.ref("stock.stock_location_components")
- cls.shelf2 = cls.env.ref("stock.stock_location_14")
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck")
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
@@ -37,29 +44,49 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="single_pack_putaway")
- def test_single_pack_putaway(self):
+ def test_start(self):
+ """Test the happy path for single pack putaway /start endpoint
+
+ The pre-conditions:
+
+ * A Pack exists in the Input Location (presumably brought there by a
+ reception for a PO)
+ * A put-away rule moves the product of the Pack from Stock to Stock/Shelf 1
+
+ Expected result:
+
+ * A move is created from Input to Stock/Shelf 1. It is assigned and the package
+ level is set to Done.
+
+ The next step in the workflow is to call /validate with the created
+ package level that will set the move and picking to done.
+ """
barcode = self.packA.name
params = {"barcode": barcode}
# Simulate the client scanning a package's barcode, which
# in turns should start the operation in odoo
response = self.service.dispatch("start", params=params)
- # the response
# Checks:
package_level = self.env["stock.package_level"].browse(response["data"]["id"])
move_line = package_level.move_line_ids
move = move_line.move_id
+ # the put-away rule should have set the shelf1 for the move line
self.assertRecordValues(
- move_line, [{"qty_done": 1.0, "location_dest_id": self.stock_location.id}]
+ move_line, [{"qty_done": 1.0, "location_dest_id": self.shelf1.id}]
)
self.assertRecordValues(
move, [{"state": "assigned", "location_dest_id": self.stock_location.id}]
)
+ # self.assertDictEqual(
+ # response,
+ # {
+ # # TODO (mock any for ids?)
+ # },
+ # )
- def test_validate(self):
- # setup the picking as we need, like if the move line
- # was already started by the first step (start operation)
+ def _simulate_started(self):
picking_form = Form(self.env["stock.picking"])
picking_form.picking_type_id = self.menu.process_id.picking_type_ids
with picking_form.move_ids_without_package.new() as move:
@@ -73,6 +100,22 @@ def test_validate(self):
# at this point, the package level is already set to "done", by the
# "start" method of the pack transfer putaway
package_level.is_done = True
+ return package_level
+
+ def test_validate(self):
+ """Test the happy path for single pack putaway /validate endpoint
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * The move associated to the package level is 'done'
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
# now, call the service to proceed with validation of the
# movement
@@ -80,14 +123,14 @@ def test_validate(self):
"validate",
params={
"package_level_id": package_level.id,
- "location_barcode": self.shelf2.barcode,
+ "location_barcode": self.shelf1.barcode,
},
)
self.assertDictEqual(
response,
{
"message": {
- "body": "The pack has been moved," " you can scan a new pack.",
+ "message": "The pack has been moved, you can scan a new pack.",
"message_type": "info",
"title": "Start",
},
@@ -97,9 +140,252 @@ def test_validate(self):
self.assertRecordValues(
package_level.move_line_ids,
- [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}],
+ [{"qty_done": 1.0, "location_dest_id": self.shelf1.id, "state": "done"}],
)
self.assertRecordValues(
package_level.move_line_ids.move_id,
[{"location_dest_id": self.stock_location.id, "state": "done"}],
)
+
+ def test_validate_not_found(self):
+ """Test a call on /validate on package level not found
+
+ Expected result:
+
+ * No change in odoo, Transition with a message
+ """
+ response = self.service.dispatch(
+ "validate",
+ params={"package_level_id": -1, "location_barcode": self.shelf1.barcode},
+ )
+ self.assertDictEqual(
+ response,
+ {
+ "message": {
+ "message": "This operation does not exist anymore.",
+ "message_type": "error",
+ "title": "Start again",
+ },
+ "state": "start",
+ },
+ )
+
+ def test_validate_location_not_found(self):
+ """Test a call on /validate on location not found
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * No change in odoo, Transition with a message
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": "THIS_BARCODE_DOES_NOT_EXISTS",
+ },
+ )
+ self.assertDictEqual(
+ response,
+ {
+ "message": {
+ "message": "No location found for this barcode.",
+ "message_type": "error",
+ "title": "Scan",
+ },
+ "state": "scan_location",
+ },
+ )
+
+ def test_validate_location_forbidden(self):
+ """Test a call on /validate on a forbidden location
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * No change in odoo, Transition with a message
+
+ Note: a forbidden location is when a location is not a child
+ of the destination location of the picking type used for the process
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ # this location is outside of the expected destination
+ "location_barcode": self.dispatch_location.barcode,
+ },
+ )
+ self.assertDictEqual(
+ response,
+ {
+ "message": {
+ "message": "You cannot place it here",
+ "message_type": "error",
+ "title": "Forbidden",
+ },
+ "state": "scan_location",
+ },
+ )
+
+ def test_cancel(self):
+ """Test the happy path for single pack putaway /cancel endpoint
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * The move associated to the package level is 'cancel'
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ # keep references for later checks
+ move = package_level.move_line_ids.move_id
+ move_lines = package_level.move_line_ids
+ picking = move.picking_id
+
+ # now, call the service to cancel
+ response = self.service.dispatch(
+ "cancel", params={"package_level_id": package_level.id}
+ )
+ self.assertRecordValues(move, [{"state": "cancel"}])
+ self.assertRecordValues(picking, [{"state": "cancel"}])
+ self.assertFalse(package_level.move_line_ids)
+ self.assertFalse(move_lines.exists())
+
+ self.assertDictEqual(
+ response,
+ {
+ "message": {
+ "message": "The move has been canceled, you can scan a new pack.",
+ "message_type": "info",
+ "title": "Start",
+ },
+ "state": "start",
+ },
+ )
+
+ def test_cancel_already_canceled(self):
+ """Test a call on /cancel for already canceled move
+
+ The pre-conditions:
+
+ * /start has been called
+ * /cancel has been called elsewhere or the move canceled on Odoo
+
+ Expected result:
+
+ * Nothing happens, transition with a message
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ # keep references for later checks
+ move = package_level.move_line_ids.move_id
+ move_lines = package_level.move_line_ids
+ picking = move.picking_id
+
+ # someone cancel the work started by our operator
+ move._action_cancel()
+
+ # now, call the service to cancel
+ response = self.service.dispatch(
+ "cancel", params={"package_level_id": package_level.id}
+ )
+ self.assertRecordValues(move, [{"state": "cancel"}])
+ self.assertRecordValues(picking, [{"state": "cancel"}])
+ self.assertFalse(package_level.move_line_ids)
+ self.assertFalse(move_lines.exists())
+
+ self.assertDictEqual(
+ response,
+ {
+ "message": {
+ "message": "The move has been canceled, you can scan a new pack.",
+ "message_type": "info",
+ "title": "Start",
+ },
+ "state": "start",
+ },
+ )
+
+ def test_cancel_already_done(self):
+ """Test a call on /cancel on move already done
+
+ The pre-conditions:
+
+ * /start has been called
+ * /validate has been called or move set to done in odoo
+
+ Expected result:
+
+ * No change in odoo, Transition with a message
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ # keep references for later checks
+ move = package_level.move_line_ids.move_id
+ picking = move.picking_id
+
+ # someone cancel the work started by our operator
+ move._action_done()
+
+ # now, call the service to cancel
+ response = self.service.dispatch(
+ "cancel", params={"package_level_id": package_level.id}
+ )
+ self.assertRecordValues(move, [{"state": "done"}])
+ self.assertRecordValues(picking, [{"state": "done"}])
+
+ self.assertDictEqual(
+ response,
+ {
+ "message": {
+ "message": "Move already processed.",
+ "message_type": "info",
+ "title": "Start",
+ },
+ "state": "start",
+ },
+ )
+
+ def test_cancel_not_found(self):
+ """Test a call on /cancel on package level not found
+
+ Expected result:
+
+ * No change in odoo, Transition with a message
+ """
+ response = self.service.dispatch("cancel", params={"package_level_id": -1})
+ self.assertDictEqual(
+ response,
+ {
+ "message": {
+ "message": "This operation does not exist anymore.",
+ "message_type": "error",
+ "title": "Start again",
+ },
+ "state": "start",
+ },
+ )
From 27aae154a62fc47399aac9b879543e904ea3e406 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 6 Feb 2020 09:47:28 +0100
Subject: [PATCH 063/940] Add component to share search methods
So every scenario search records from a barcode the same way.
---
shopfloor/actions/__init__.py | 1 +
shopfloor/actions/pack_transfer_validate.py | 4 ----
shopfloor/actions/search.py | 19 ++++++++++++++++++
shopfloor/services/pack.py | 13 ++++--------
shopfloor/services/single_pack_putaway.py | 22 ++++++++++++---------
shopfloor/services/single_pack_transfer.py | 12 ++++++++---
6 files changed, 46 insertions(+), 25 deletions(-)
create mode 100644 shopfloor/actions/search.py
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index af4a9151a5..0a39e1f9af 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -18,3 +18,4 @@
"""
from . import base_action
from . import pack_transfer_validate
+from . import search
diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py
index 19c5f25ccb..74af3071db 100644
--- a/shopfloor/actions/pack_transfer_validate.py
+++ b/shopfloor/actions/pack_transfer_validate.py
@@ -14,9 +14,6 @@ class PackTransferValidateAction(Component):
_inherit = "shopfloor.process.action"
_usage = "pack.transfer.validate"
- def location_from_scan(self, barcode):
- return self.env["stock.location"].search([("barcode", "=", barcode)])
-
def is_move_state_valid(self, move):
return move.state != "cancel"
@@ -42,6 +39,5 @@ def is_dest_location_to_confirm(self, move, scanned_location):
return scanned_location not in zone_locations
def set_destination_and_done(self, move, scanned_location):
-
move.move_line_ids[0].location_dest_id = scanned_location.id
move._action_done()
diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py
new file mode 100644
index 0000000000..706b6c057e
--- /dev/null
+++ b/shopfloor/actions/search.py
@@ -0,0 +1,19 @@
+from odoo.addons.component.core import Component
+
+
+class SearchAction(Component):
+ """Provide methods to search records from scanner
+
+ The methods should be used in Service Components, so a search will always
+ have the same result in all scenarios.
+ """
+
+ _name = "shopfloor.search.action"
+ _inherit = "shopfloor.process.action"
+ _usage = "search"
+
+ def location_from_scan(self, barcode):
+ return self.env["stock.location"].search([("barcode", "=", barcode)])
+
+ def package_from_scan(self, barcode):
+ return self.env["stock.quant.package"].search([("name", "=", barcode)])
diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py
index e13ea02c5e..06117ef483 100644
--- a/shopfloor/services/pack.py
+++ b/shopfloor/services/pack.py
@@ -11,15 +11,10 @@ class ShopfloorPack(Component):
_description = __doc__
def get_by_name(self, pack_name):
- """
- Get pack informations
- """
- pack = self.env["stock.quant.package"].search(
- [("name", "=", pack_name)],
- # TODO, is it what we want? error if not found?
- limit=1,
- )
- return self._response(data=self._to_json(pack)[:1])
+ """Get pack information"""
+ search = self.actions_for("search")
+ package = search.package_from_scan(pack_name)
+ return self._response(data=self._to_json(package)[:1])
def _validator_get_by_name(self):
return {"pack_name": {"type": "string", "nullable": False, "required": True}}
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 61ee7a2476..dba2d88af0 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -12,6 +12,8 @@ class SinglePackPutaway(Component):
_usage = "single_pack_putaway"
_description = __doc__
+ # TODO create an Action component for messages, so we use the
+ # same messages in every scenario
def _response_for_no_picking_type(self):
return self._response(
state="start",
@@ -131,14 +133,15 @@ def start(self, barcode):
return self._response_for_several_picking_types()
elif not picking_type:
return self._response_for_no_picking_type()
- company = self.env.company
- # TODO define on what we search (pack name, pack barcode ...)
- # we can create a component with the search methods (easy to override)
- pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
+ search = self.actions_for("search")
+ pack = search.package_from_scan(barcode)
if not pack:
return self._response_for_package_not_found(barcode)
+ assert len(pack) == 1, "We cannot have 2 packages with the same barcode"
+ # TODO this seems to be a pretty common check, consider moving
+ # it to an Action Component
allowed_locations = self.env["stock.location"].search(
[("id", "child_of", picking_type.default_location_src_id.id)]
)
@@ -156,10 +159,11 @@ def start(self, barcode):
return self._response_for_forbidden_start(existing_operations)
elif existing_operations:
return self._response_for_start_to_confirm(existing_operations, pack)
- product = pack.quant_ids[
- 0
- ].product_id # FIXME we consider only one product per pack
+ # FIXME we consider only one product per pack
+ product = pack.quant_ids[0].product_id
default_location_dest = picking_type.default_location_dest_id
+ company = self.env.company
+ # TODO _prepare methods
move_vals = {
"picking_type_id": picking_type.id,
"product_id": product.id,
@@ -247,6 +251,7 @@ def _response_for_validate_success(self):
def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
pack_transfer = self.actions_for("pack.transfer.validate")
+ search = self.actions_for("search")
package = self.env["stock.package_level"].browse(package_level_id)
if not package.exists():
@@ -256,8 +261,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if not pack_transfer.is_move_state_valid(move):
return self._response_for_move_canceled()
- # TODO move scan search in a component for this?
- scanned_location = pack_transfer.location_from_scan(location_barcode)
+ scanned_location = search.location_from_scan(location_barcode)
if not scanned_location:
return self._response_for_location_not_found()
if not pack_transfer.is_dest_location_valid(move, scanned_location):
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 2aea9d7ae7..f3fc1df67d 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -110,7 +110,10 @@ def _response_for_start_success(self, move, pack):
)
def start(self, barcode):
- location = self.env["stock.location"].search([("barcode", "=", barcode)])
+ search = self.actions_for("search")
+
+ location = search.location_from_scan(barcode)
+
pack = self.env["stock.quant.package"]
if location:
pack = self.env["stock.quant.package"].search(
@@ -120,8 +123,10 @@ def start(self, barcode):
return self._response_for_empty_location(location)
if len(pack) > 1:
return self._response_for_several_packages(self, location)
+
if not pack:
- pack = self.env["stock.quant.package"].search([("name", "=", barcode)])
+ pack = search.package_from_scan(barcode)
+
if not pack:
return self._response_for_package_not_found(barcode)
@@ -232,6 +237,7 @@ def _response_for_validate_success(self):
def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
pack_transfer = self.actions_for("pack.transfer.validate")
+ search = self.actions_for("search")
package = self.env["stock.package_level"].browse(package_level_id)
if not package.exists():
@@ -241,7 +247,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if not pack_transfer.is_move_state_valid(move):
return self._response_for_move_canceled()
- scanned_location = pack_transfer.location_from_scan(location_barcode)
+ scanned_location = search.location_from_scan(location_barcode)
if not pack_transfer.is_dest_location_valid(move, scanned_location):
return self._response_for_forbidden_location()
From 03aa15ca920ef99f7c89b39410a12f141f894429 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 6 Feb 2020 11:40:23 +0100
Subject: [PATCH 064/940] Add a Component for messages
The methods should be used in Service Components, in order to share as much
as possible the messages for similar events.
---
shopfloor/actions/__init__.py | 1 +
shopfloor/actions/message.py | 142 ++++++++++++++++++++
shopfloor/services/single_pack_putaway.py | 142 ++++++--------------
shopfloor/services/single_pack_transfer.py | 106 ++++-----------
shopfloor/tests/test_single_pack_putaway.py | 12 +-
5 files changed, 214 insertions(+), 189 deletions(-)
create mode 100644 shopfloor/actions/message.py
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index 0a39e1f9af..a6719c5d2f 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -17,5 +17,6 @@
"""
from . import base_action
+from . import message
from . import pack_transfer_validate
from . import search
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
new file mode 100644
index 0000000000..3d19bcf64b
--- /dev/null
+++ b/shopfloor/actions/message.py
@@ -0,0 +1,142 @@
+from odoo import _
+
+from odoo.addons.component.core import Component
+
+
+class MessageAction(Component):
+ """Provide message templates
+
+ The methods should be used in Service Components, in order to share as much
+ as possible the messages for similar events.
+
+ Before adding a message, please look if no message already exists,
+ and consider making an existing message more generic.
+ """
+
+ _name = "shopfloor.message.action"
+ _inherit = "shopfloor.process.action"
+ _usage = "message"
+
+ def no_picking_type(self):
+ return {
+ "message_type": "error",
+ "title": _("Configuration error"),
+ "message": _("No operation type found for this menu and profile."),
+ }
+
+ def several_picking_types(self):
+ return {
+ "message_type": "error",
+ "title": _("Configuration error"),
+ "message": _("Several operation types found for this menu and profile."),
+ }
+
+ def package_not_found_for_barcode(self, barcode):
+ return {
+ "message_type": "error",
+ "title": _("Package not found"),
+ "message": _("The package %s doesn't exist") % barcode,
+ }
+
+ def package_not_allowed_in_src_location(self, barcode, picking_type):
+ return {
+ "message_type": "error",
+ "title": _("Cannot proceed"),
+ "message": _("You cannot work on a package (%s) outside of location: %s")
+ % (barcode, picking_type.default_location_src_id.name),
+ }
+
+ def already_running_ask_confirmation(self):
+ return {
+ "message_type": "warning",
+ "title": _("Already started"),
+ "message": _(
+ "Operation's already running. Would you like to take it over?"
+ ),
+ }
+
+ def scan_destination(self):
+ return {
+ "message_type": "info",
+ "title": _("Scan"),
+ "message": _("Scan the destination location"),
+ }
+
+ def operation_not_found(self):
+ return {
+ "message_type": "error",
+ "title": _("Start again"),
+ "message": _("This operation does not exist anymore."),
+ }
+
+ def operation_has_been_canceled_elsewhere(self):
+ return {
+ "message_type": "warning",
+ "title": _("Restart"),
+ "message": _("Restart the operation, someone has canceled it."),
+ }
+
+ def no_location_found(self):
+ return {
+ "message_type": "error",
+ "title": _("Scan"),
+ "message": _("No location found for this barcode."),
+ }
+
+ def dest_location_not_allowed(self):
+ return {
+ "message_type": "error",
+ "title": _("Forbidden"),
+ "message": _("You cannot place it here"),
+ }
+
+ def need_confirmation(self):
+ return {
+ "message_type": "warning",
+ "title": _("Confirm"),
+ "message": _("Are you sure?"),
+ }
+
+ def confirm_pack_moved(self):
+ return {
+ "message_type": "info",
+ "title": _("Start"),
+ "message": _("The pack has been moved, you can scan a new pack."),
+ }
+
+ def already_done(self):
+ return {
+ "message_type": "info",
+ "title": _("Continue"),
+ "message": _("Operation already processed."),
+ }
+
+ def confirm_canceled_scan_next_pack(self):
+ return {
+ "message_type": "info",
+ "title": _("Continue"),
+ "message": _("Canceled, you can scan a new pack."),
+ }
+
+ def no_pack_in_location(self, location):
+ return {
+ "message_type": "error",
+ "title": _("Scan package"),
+ "message": _("Location %s doesn’t contain any package." % location.name),
+ }
+
+ def several_packs_in_location(self, location):
+ return {
+ "message_type": "error",
+ "title": _("Scan package"),
+ "message": _(
+ "Several PACKs found in %s, please scan one PACK." % location.name
+ ),
+ }
+
+ def no_pending_operation_for_pack(self, pack):
+ return {
+ "message_type": "error",
+ "title": _("Error"),
+ "message": _("No pending operation for package %s." % pack.name),
+ }
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index dba2d88af0..8b7e7fa64d 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -12,47 +12,24 @@ class SinglePackPutaway(Component):
_usage = "single_pack_putaway"
_description = __doc__
- # TODO create an Action component for messages, so we use the
- # same messages in every scenario
def _response_for_no_picking_type(self):
- return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Configuration error"),
- "message": _("No picking types found for this menu and profile"),
- },
- )
+ message = self.actions_for("message")
+ return self._response(state="start", message=message.no_picking_type())
def _response_for_several_picking_types(self):
- return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Configuration error"),
- "message": _("Several picking types found for this menu and profile"),
- },
- )
+ message = self.actions_for("message")
+ return self._response(state="start", message=message.several_picking_types())
def _response_for_package_not_found(self, barcode):
+ message = self.actions_for("message")
return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Pack not found"),
- "message": _("The pack %s doesn't exist") % barcode,
- },
+ state="start", message=message.package_not_found_for_barcode(barcode)
)
def _response_for_forbidden_package(self, barcode, picking_type):
+ message = self.actions_for("message")
return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Cannot proceed"),
- "message": _("pack %s is not in %s location")
- % (barcode, picking_type.default_location_src_id.name),
- },
+ state="start", message=message.package_not_allowed_in_src_location()
)
def _response_for_forbidden_start(self, existing_operations):
@@ -73,6 +50,7 @@ def _response_for_forbidden_start(self, existing_operations):
)
def _response_for_start_to_confirm(self, existing_operations, pack):
+ message = self.actions_for("message")
move = existing_operations.move_id
return self._response(
data={
@@ -90,25 +68,14 @@ def _response_for_start_to_confirm(self, existing_operations, pack):
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
},
state="confirm_start",
- message={
- "message_type": "warning",
- "title": _("Already started"),
- "message": _(
- "Operation already running. Would you like to take it over ?"
- ),
- },
+ message=message.already_running_ask_confirmation(),
)
def _response_for_start_success(self, move, pack):
+ message = self.actions_for("message")
return self._response(
state="scan_location",
- message={
- "message_type": "info",
- "title": _("Start"),
- "message": _(
- "The move is ready, you can scan the destination location."
- ),
- },
+ message=message.scan_destination(),
data={
"id": move.move_line_ids[0].package_level_id.id,
"name": pack.name,
@@ -189,64 +156,36 @@ def start(self, barcode):
return self._response_for_start_success(move, pack)
def _response_for_package_level_not_found(self):
- return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Start again"),
- "message": _("This operation does not exist anymore."),
- },
- )
+ message = self.actions_for("message")
+ return self._response(state="start", message=message.operation_not_found())
- def _response_for_move_canceled(self):
+ def _response_for_move_canceled_elsewhere(self):
+ message = self.actions_for("message")
return self._response(
- state="start",
- message={
- "message_type": "warning",
- "title": _("Restart"),
- "message": _("Restart the operation, someone has canceled it."),
- },
+ state="start", message=message.operation_has_been_canceled_elsewhere()
)
def _response_for_location_not_found(self):
+ message = self.actions_for("message")
return self._response(
- state="scan_location",
- message={
- "message_type": "error",
- "title": _("Scan"),
- "message": _("No location found for this barcode."),
- },
+ state="scan_location", message=message.no_location_found()
)
def _response_for_forbidden_location(self):
+ message = self.actions_for("message")
return self._response(
- state="scan_location",
- message={
- "message_type": "error",
- "title": _("Forbidden"),
- "message": _("You cannot place it here"),
- },
+ state="scan_location", message=message.dest_location_not_allowed()
)
def _response_for_location_need_confirm(self):
+ message = self.actions_for("message")
return self._response(
- state="confirm_location",
- message={
- "message_type": "warning",
- "title": _("Confirm"),
- "message": _("Are you sure?"),
- },
+ state="confirm_location", message=message.need_confirmation()
)
def _response_for_validate_success(self):
- return self._response(
- state="start",
- message={
- "message_type": "info",
- "title": _("Start"),
- "message": _("The pack has been moved, you can scan a new pack."),
- },
- )
+ message = self.actions_for("message")
+ return self._response(state="start", message=message.confirm_pack_moved())
def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
@@ -259,7 +198,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
move = package.move_line_ids[0].move_id
if not pack_transfer.is_move_state_valid(move):
- return self._response_for_move_canceled()
+ return self._response_for_move_canceled_elsewhere()
scanned_location = search.location_from_scan(location_barcode)
if not scanned_location:
@@ -278,6 +217,16 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
pack_transfer.set_destination_and_done(move, scanned_location)
return self._response_for_validate_success()
+ def _response_for_move_already_processed(self):
+ message = self.actions_for("message")
+ return self._response(state="start", message=message.already_done())
+
+ def _response_for_confirm_move_cancellation(self):
+ message = self.actions_for("message")
+ return self._response(
+ state="start", message=message.confirm_canceled_scan_next_pack()
+ )
+
def cancel(self, package_level_id):
package = self.env["stock.package_level"].browse(package_level_id)
if not package.exists():
@@ -285,23 +234,10 @@ def cancel(self, package_level_id):
# package.move_ids may be empty, it seems
move = package.move_line_ids.move_id
if move.state == "done":
- return self._response(
- state="start",
- message={
- "message_type": "info",
- "title": _("Start"),
- "message": _("Move already processed."),
- },
- )
+ return self._response_for_move_already_processed()
+
package.move_line_ids.move_id._action_cancel()
- return self._response(
- state="start",
- message={
- "message_type": "info",
- "title": _("Start"),
- "message": _("The move has been canceled, you can scan a new pack."),
- },
- )
+ return self._response_for_confirm_move_cancellation()
def _validator_cancel(self):
return {
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index f3fc1df67d..1e26827324 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -1,5 +1,3 @@
-from odoo import _
-
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -13,50 +11,31 @@ class SinglePackTransfer(Component):
_description = __doc__
def _response_for_empty_location(self, location):
+ message = self.actions_for("message")
return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Scan package"),
- "message": _("Location %s doesn’t contain any PACK." % location.name),
- },
+ state="start", message=message.no_pack_in_location(location)
)
def _response_for_several_packages(self, location):
+ message = self.actions_for("message")
return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Scan package"),
- "message": _(
- "Several PACKs found in %s, please scan one PACK." % location.name
- ),
- },
+ state="start", message=message.several_packs_in_location(location)
)
def _response_for_package_not_found(self, barcode):
+ message = self.actions_for("message")
return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Pack not found"),
- "message": _("The pack %s doesn't exist") % barcode,
- },
+ state="start", message=message.package_not_found_for_barcode(barcode)
)
- def _response_for_operation_not_found(self):
+ def _response_for_operation_not_found(self, pack):
+ message = self.actions_for("message")
return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Not found"),
- "message": _(
- "No pending operation exists for this PACK you cannot process it."
- ),
- },
+ state="start", message=message.no_pending_operation_for_pack(pack)
)
def _response_for_start_to_confirm(self, existing_operations, pack):
+ message = self.actions_for("message")
move = existing_operations.move_id
return self._response(
data={
@@ -74,25 +53,14 @@ def _response_for_start_to_confirm(self, existing_operations, pack):
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
},
state="confirm_start",
- message={
- "message_type": "warning",
- "title": _("Already started"),
- "message": _(
- "Operation already running. Would you like to take it over ?"
- ),
- },
+ message=message.already_running_ask_confirmation(),
)
def _response_for_start_success(self, move, pack):
+ message = self.actions_for("message")
return self._response(
state="scan_location",
- message={
- "message_type": "info",
- "title": _("Start"),
- "message": _(
- "The move is ready, you can scan the destination location."
- ),
- },
+ message=message.scan_destination(),
data={
"id": move.move_line_ids[0].package_level_id.id,
"name": pack.name,
@@ -137,7 +105,7 @@ def start(self, barcode):
]
)
if not existing_operations:
- return self._response_for_operation_not_found()
+ return self._response_for_operation_not_found(pack)
move = existing_operations.move_id
if existing_operations[0].package_level_id.is_done:
return self._response_for_start_to_confirm(existing_operations, pack)
@@ -185,54 +153,30 @@ def _validator_return_start(self):
)
def _response_for_package_level_not_found(self):
- return self._response(
- state="start",
- message={
- "message_type": "error",
- "title": _("Start again"),
- "message": _("This operation does not exist anymore."),
- },
- )
+ message = self.actions_for("message")
+ return self._response(state="start", message=message.operation_not_found())
def _response_for_move_canceled(self):
+ message = self.actions_for("message")
return self._response(
- state="start",
- message={
- "message_type": "warning",
- "title": _("Restart"),
- "message": _("Restart the operation, someone has canceled it."),
- },
+ state="start", message=message.operation_has_been_canceled_elsewhere()
)
def _response_for_forbidden_location(self):
+ message = self.actions_for("message")
return self._response(
- state="scan_location",
- message={
- "message_type": "error",
- "title": _("Forbidden"),
- "message": _("You cannot place it here"),
- },
+ state="scan_location", message=message.dest_location_not_allowed()
)
def _response_for_location_need_confirm(self):
+ message = self.actions_for("message")
return self._response(
- state="confirm_location",
- message={
- "message_type": "warning",
- "title": _("Confirm"),
- "message": _("Are you sure?"),
- },
+ state="confirm_location", message=message.need_confirmation()
)
def _response_for_validate_success(self):
- return self._response(
- state="start",
- message={
- "message_type": "info",
- "title": _("Start"),
- "message": _("The pack has been moved, you can scan a new pack."),
- },
- )
+ message = self.actions_for("message")
+ return self._response(state="start", message=message.confirm_pack_moved())
def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
@@ -271,3 +215,5 @@ def _validator_validate(self):
def _validator_return_validate(self):
return self._response_schema()
+
+ # TODO cancel
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index a5997feac1..b1a183f658 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -275,9 +275,9 @@ def test_cancel(self):
response,
{
"message": {
- "message": "The move has been canceled, you can scan a new pack.",
+ "message": "Canceled, you can scan a new pack.",
"message_type": "info",
- "title": "Start",
+ "title": "Continue",
},
"state": "start",
},
@@ -320,9 +320,9 @@ def test_cancel_already_canceled(self):
response,
{
"message": {
- "message": "The move has been canceled, you can scan a new pack.",
+ "message": "Canceled, you can scan a new pack.",
"message_type": "info",
- "title": "Start",
+ "title": "Continue",
},
"state": "start",
},
@@ -362,9 +362,9 @@ def test_cancel_already_done(self):
response,
{
"message": {
- "message": "Move already processed.",
+ "message": "Operation already processed.",
"message_type": "info",
- "title": "Start",
+ "title": "Continue",
},
"state": "start",
},
From 35b56d2e2e5dbbb39e70fa61d7a0aaeeb049a26e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 6 Feb 2020 12:04:14 +0100
Subject: [PATCH 065/940] Use _prepare methods in putaway
---
shopfloor/services/single_pack_putaway.py | 25 +++++++++++++++--------
1 file changed, 16 insertions(+), 9 deletions(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 8b7e7fa64d..6db6a1037d 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -126,12 +126,22 @@ def start(self, barcode):
return self._response_for_forbidden_start(existing_operations)
elif existing_operations:
return self._response_for_start_to_confirm(existing_operations, pack)
+
+ move_vals = self._prepare_stock_move(picking_type, pack)
+ move = self.env["stock.move"].create(move_vals)
+ move._action_confirm(merge=False)
+ package_level = self._prepare_package_level(pack, move)
+ move._action_assign()
+ package_level.is_done = True
+ return self._response_for_start_success(move, pack)
+
+ def _prepare_stock_move(self, picking_type, pack):
# FIXME we consider only one product per pack
+ assert len(pack.quant_ids) == 1
product = pack.quant_ids[0].product_id
default_location_dest = picking_type.default_location_dest_id
company = self.env.company
- # TODO _prepare methods
- move_vals = {
+ return {
"picking_type_id": picking_type.id,
"product_id": product.id,
"location_id": pack.location_id.id,
@@ -141,19 +151,16 @@ def start(self, barcode):
"product_uom_qty": pack.quant_ids[0].quantity,
"company_id": company.id,
}
- move = self.env["stock.move"].create(move_vals)
- move._action_confirm(merge=False)
- package_level = self.env["stock.package_level"].create(
+
+ def _prepare_package_level(self, pack, move):
+ return self.env["stock.package_level"].create(
{
"package_id": pack.id,
"move_ids": [(4, move.id)],
- "company_id": company.id,
+ "company_id": self.env.company.id,
"picking_id": move.picking_id.id,
}
)
- move._action_assign()
- package_level.is_done = True
- return self._response_for_start_success(move, pack)
def _response_for_package_level_not_found(self):
message = self.actions_for("message")
From 396065ed2ba0307cb661dbaf65c9e3bc53dff49e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 6 Feb 2020 15:14:40 +0100
Subject: [PATCH 066/940] Add test utilities to check webservice responses
---
shopfloor/tests/common.py | 77 +++++++++++
shopfloor/tests/test_single_pack_putaway.py | 142 ++++++++++----------
2 files changed, 149 insertions(+), 70 deletions(-)
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 1f14f5ac93..372fbd187c 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -1,4 +1,6 @@
from contextlib import contextmanager
+from copy import deepcopy
+from pprint import pformat
from odoo.tests.common import SavepointCase
@@ -7,6 +9,17 @@
from odoo.addons.component.tests.common import ComponentMixin
+class AnyObject:
+ def __repr__(self):
+ return "ANY"
+
+ def __deepcopy__(self, memodict=None):
+ return self
+
+ def __copy__(self):
+ return self
+
+
class CommonCase(SavepointCase, ComponentMixin):
# by default disable tracking suite-wise, it's a time saver :)
@@ -36,3 +49,67 @@ def setUpClass(cls):
context=dict(cls.env.context, tracking_disable=cls.tracking_disable)
)
cls.setUpComponent()
+
+ def assert_response(self, response, state=None, message=None, data=None):
+ """Assert a response from the webservice
+
+ The data and message dictionaries are checked using
+ ``self.assert_dict``, which means we can use ``self.ANY`` to accept any
+ value.
+ """
+ expected = {}
+ if state:
+ expected["state"] = state
+ if message:
+ expected["message"] = message
+ if data:
+ expected["data"] = data
+ self.assert_dict(response, expected)
+
+ ANY = AnyObject()
+
+ def assert_dict(self, current, expected):
+ """Assert dictionary equality with support of wildcard values
+
+ In the expected dictionary, instead of a value, ``self.ANY``
+ can be provided, which will accept any value.
+
+ For instance, an expected value for a response which accepts any
+ content could be::
+
+ {
+ "data": self.ANY,
+ "message": {
+ "title": self.ANY,
+ "message_type": self.ANY,
+ "message": self.ANY,
+ },
+ "state": self.ANY,
+ }
+
+ Note: if ``self.ANY`` is used, the key must exist in the dictionary
+ to check.
+ """
+ next_checks = [(current, expected)]
+ while next_checks:
+ original, node_original_expected = next_checks.pop()
+ node_values = deepcopy(original)
+ node_expected = deepcopy(node_original_expected)
+
+ for key in original:
+ # sub-dictionaries will be checked later
+ expected_value = node_expected.get(key)
+ if expected_value is self.ANY:
+ # ignore 'any' keys
+ node_values.pop(key)
+ node_expected.pop(key)
+ continue
+ if isinstance(expected_value, dict):
+ next_checks.append((node_values.pop(key), node_expected.pop(key)))
+ continue
+
+ self.assertDictEqual(
+ node_values,
+ node_expected,
+ "\n\nNode's specs:\n%s" % (pformat(node_original_expected)),
+ )
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index b1a183f658..9e879f3fc1 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -79,12 +79,26 @@ def test_start(self):
self.assertRecordValues(
move, [{"state": "assigned", "location_dest_id": self.stock_location.id}]
)
- # self.assertDictEqual(
- # response,
- # {
- # # TODO (mock any for ids?)
- # },
- # )
+ self.assert_response(
+ response,
+ state="scan_location",
+ message={
+ "title": self.ANY,
+ "message_type": "info",
+ "message": "Scan the destination location",
+ },
+ data={
+ "id": self.ANY,
+ "location_src": {
+ "id": self.dispatch_location.id,
+ "name": self.dispatch_location.name,
+ },
+ "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name},
+ "name": package_level.package_id.name,
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ "product": {"id": move.product_id.id, "name": move.product_id.name},
+ },
+ )
def _simulate_started(self):
picking_form = Form(self.env["stock.picking"])
@@ -126,15 +140,14 @@ def test_validate(self):
"location_barcode": self.shelf1.barcode,
},
)
- self.assertDictEqual(
+
+ self.assert_response(
response,
- {
- "message": {
- "message": "The pack has been moved, you can scan a new pack.",
- "message_type": "info",
- "title": "Start",
- },
- "state": "start",
+ state="start",
+ message={
+ "title": self.ANY,
+ "message_type": "info",
+ "message": "The pack has been moved, you can scan a new pack.",
},
)
@@ -158,15 +171,14 @@ def test_validate_not_found(self):
"validate",
params={"package_level_id": -1, "location_barcode": self.shelf1.barcode},
)
- self.assertDictEqual(
+
+ self.assert_response(
response,
- {
- "message": {
- "message": "This operation does not exist anymore.",
- "message_type": "error",
- "title": "Start again",
- },
- "state": "start",
+ state="start",
+ message={
+ "title": self.ANY,
+ "message_type": "error",
+ "message": "This operation does not exist anymore.",
},
)
@@ -192,15 +204,14 @@ def test_validate_location_not_found(self):
"location_barcode": "THIS_BARCODE_DOES_NOT_EXISTS",
},
)
- self.assertDictEqual(
+
+ self.assert_response(
response,
- {
- "message": {
- "message": "No location found for this barcode.",
- "message_type": "error",
- "title": "Scan",
- },
- "state": "scan_location",
+ state="scan_location",
+ message={
+ "title": self.ANY,
+ "message_type": "error",
+ "message": "No location found for this barcode.",
},
)
@@ -230,15 +241,14 @@ def test_validate_location_forbidden(self):
"location_barcode": self.dispatch_location.barcode,
},
)
- self.assertDictEqual(
+
+ self.assert_response(
response,
- {
- "message": {
- "message": "You cannot place it here",
- "message_type": "error",
- "title": "Forbidden",
- },
- "state": "scan_location",
+ state="scan_location",
+ message={
+ "title": self.ANY,
+ "message_type": "error",
+ "message": "You cannot place it here",
},
)
@@ -271,15 +281,13 @@ def test_cancel(self):
self.assertFalse(package_level.move_line_ids)
self.assertFalse(move_lines.exists())
- self.assertDictEqual(
+ self.assert_response(
response,
- {
- "message": {
- "message": "Canceled, you can scan a new pack.",
- "message_type": "info",
- "title": "Continue",
- },
- "state": "start",
+ state="start",
+ message={
+ "title": self.ANY,
+ "message_type": "info",
+ "message": "Canceled, you can scan a new pack.",
},
)
@@ -316,15 +324,13 @@ def test_cancel_already_canceled(self):
self.assertFalse(package_level.move_line_ids)
self.assertFalse(move_lines.exists())
- self.assertDictEqual(
+ self.assert_response(
response,
- {
- "message": {
- "message": "Canceled, you can scan a new pack.",
- "message_type": "info",
- "title": "Continue",
- },
- "state": "start",
+ state="start",
+ message={
+ "title": self.ANY,
+ "message_type": "info",
+ "message": "Canceled, you can scan a new pack.",
},
)
@@ -358,15 +364,13 @@ def test_cancel_already_done(self):
self.assertRecordValues(move, [{"state": "done"}])
self.assertRecordValues(picking, [{"state": "done"}])
- self.assertDictEqual(
+ self.assert_response(
response,
- {
- "message": {
- "message": "Operation already processed.",
- "message_type": "info",
- "title": "Continue",
- },
- "state": "start",
+ state="start",
+ message={
+ "title": self.ANY,
+ "message_type": "info",
+ "message": "Operation already processed.",
},
)
@@ -378,14 +382,12 @@ def test_cancel_not_found(self):
* No change in odoo, Transition with a message
"""
response = self.service.dispatch("cancel", params={"package_level_id": -1})
- self.assertDictEqual(
+ self.assert_response(
response,
- {
- "message": {
- "message": "This operation does not exist anymore.",
- "message_type": "error",
- "title": "Start again",
- },
- "state": "start",
+ state="start",
+ message={
+ "title": self.ANY,
+ "message_type": "error",
+ "message": "This operation does not exist anymore.",
},
)
From ef47dda3d506069f6923c2d9aee62dbd8ec841e8 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 6 Feb 2020 17:18:33 +0100
Subject: [PATCH 067/940] Add test cases for the putaway
Trying to cover all the exceptions mentioned in the diagram
(https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP)
---
shopfloor/actions/message.py | 2 +
shopfloor/actions/pack_transfer_validate.py | 18 +-
shopfloor/services/single_pack_putaway.py | 108 +++++----
shopfloor/tests/test_single_pack_putaway.py | 234 ++++++++++++++++++++
4 files changed, 309 insertions(+), 53 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 3d19bcf64b..d008b58d8d 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -2,6 +2,8 @@
from odoo.addons.component.core import Component
+# TODO remove 'title', useless
+
class MessageAction(Component):
"""Provide message templates
diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py
index 74af3071db..4997e402ef 100644
--- a/shopfloor/actions/pack_transfer_validate.py
+++ b/shopfloor/actions/pack_transfer_validate.py
@@ -1,6 +1,7 @@
from odoo.addons.component.core import Component
+# TODO think about how we want to share the common methods / workflows
class PackTransferValidateAction(Component):
"""Pack Transfer shared business logic
@@ -17,26 +18,31 @@ class PackTransferValidateAction(Component):
def is_move_state_valid(self, move):
return move.state != "cancel"
+ # TODO generic method "is_location_below"
def is_dest_location_valid(self, move, scanned_location):
"""Forbid a dest location to be used"""
- allowed_locations = self.env["stock.location"].search(
+ allowed_location = self.env["stock.location"].search_count(
[
(
"id",
"child_of",
move.picking_id.picking_type_id.default_location_dest_id.id,
- )
+ ),
+ ("id", "=", scanned_location.id),
]
)
- return scanned_location in allowed_locations
+ return bool(allowed_location)
def is_dest_location_to_confirm(self, move, scanned_location):
"""Destination that could be used but need confirmation"""
move_dest_location = move.move_line_ids[0].location_dest_id
- zone_locations = self.env["stock.location"].search(
- [("id", "child_of", move_dest_location.id)]
+ in_dest_location = self.env["stock.location"].search_count(
+ [
+ ("id", "child_of", move_dest_location.id),
+ ("id", "=", scanned_location.id),
+ ]
)
- return scanned_location not in zone_locations
+ return not bool(in_dest_location)
def set_destination_and_done(self, move, scanned_location):
move.move_line_ids[0].location_dest_id = scanned_location.id
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 6db6a1037d..4325df07b5 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -12,6 +12,8 @@ class SinglePackPutaway(Component):
_usage = "single_pack_putaway"
_description = __doc__
+ # TODO think about not sending back the state when we already
+ # come from the same state
def _response_for_no_picking_type(self):
message = self.actions_for("message")
return self._response(state="start", message=message.no_picking_type())
@@ -29,7 +31,8 @@ def _response_for_package_not_found(self, barcode):
def _response_for_forbidden_package(self, barcode, picking_type):
message = self.actions_for("message")
return self._response(
- state="start", message=message.package_not_allowed_in_src_location()
+ state="start",
+ message=message.package_not_allowed_in_src_location(barcode, picking_type),
)
def _response_for_forbidden_start(self, existing_operations):
@@ -49,47 +52,34 @@ def _response_for_forbidden_start(self, existing_operations):
},
)
- def _response_for_start_to_confirm(self, existing_operations, pack):
+ def _data_for_scan(self, move_line, pack):
+ move = move_line.move_id
+ return {
+ "id": move_line.package_level_id.id,
+ "name": pack.name,
+ "location_src": {"id": pack.location_id.id, "name": pack.location_id.name},
+ "location_dst": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ "product": {"id": move.product_id.id, "name": move.product_id.name},
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ }
+
+ def _response_for_start_to_confirm(self, move_line, pack):
message = self.actions_for("message")
- move = existing_operations.move_id
return self._response(
- data={
- "id": existing_operations[0].package_level_id.id,
- "name": pack.name,
- "location_src": {
- "id": pack.location_id.id,
- "name": pack.location_id.name,
- },
- "location_dst": {
- "id": existing_operations[0].location_dest_id.id,
- "name": existing_operations[0].location_dest_id.name,
- },
- "product": {"id": move.product_id.name, "name": move.product_id.name},
- "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
- },
+ data=self._data_for_scan(move_line, pack),
state="confirm_start",
message=message.already_running_ask_confirmation(),
)
- def _response_for_start_success(self, move, pack):
+ def _response_for_start_success(self, move_line, pack):
message = self.actions_for("message")
return self._response(
state="scan_location",
message=message.scan_destination(),
- data={
- "id": move.move_line_ids[0].package_level_id.id,
- "name": pack.name,
- "location_src": {
- "id": pack.location_id.id,
- "name": pack.location_id.name,
- },
- "location_dst": {
- "id": move.move_line_ids[0].location_dest_id.id,
- "name": move.move_line_ids[0].location_dest_id.name,
- },
- "product": {"id": move.product_id.id, "name": move.product_id.name},
- "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
- },
+ data=self._data_for_scan(move_line, pack),
)
def start(self, barcode):
@@ -109,23 +99,39 @@ def start(self, barcode):
# TODO this seems to be a pretty common check, consider moving
# it to an Action Component
- allowed_locations = self.env["stock.location"].search(
- [("id", "child_of", picking_type.default_location_src_id.id)]
+ allowed_location = self.env["stock.location"].search_count(
+ [
+ ("id", "child_of", picking_type.default_location_src_id.id),
+ ("id", "=", pack.location_id.id),
+ ]
)
- if pack.location_id not in allowed_locations:
+ if not allowed_location:
return self._response_for_forbidden_package(barcode, picking_type)
- quantity = pack.quant_ids[0].quantity
- existing_operations = self.env["stock.move.line"].search(
- [("qty_done", "=", quantity), ("package_id", "=", pack.id)]
+ existing_operation = self.env["stock.move.line"].search(
+ [
+ ("package_id", "=", pack.id),
+ (
+ "state",
+ "in",
+ (
+ "assigned",
+ "draft",
+ "waiting",
+ "confirmed",
+ "partially_available",
+ ),
+ ),
+ ],
+ limit=1,
)
if (
- existing_operations
- and existing_operations[0].picking_id.picking_type_id != picking_type
+ existing_operation
+ and existing_operation[0].picking_id.picking_type_id != picking_type
):
- return self._response_for_forbidden_start(existing_operations)
- elif existing_operations:
- return self._response_for_start_to_confirm(existing_operations, pack)
+ return self._response_for_forbidden_start(existing_operation)
+ elif existing_operation:
+ return self._response_for_start_to_confirm(existing_operation, pack)
move_vals = self._prepare_stock_move(picking_type, pack)
move = self.env["stock.move"].create(move_vals)
@@ -133,7 +139,8 @@ def start(self, barcode):
package_level = self._prepare_package_level(pack, move)
move._action_assign()
package_level.is_done = True
- return self._response_for_start_success(move, pack)
+ # TODO what if we have > 1 move line?
+ return self._response_for_start_success(move.move_line_ids, pack)
def _prepare_stock_move(self, picking_type, pack):
# FIXME we consider only one product per pack
@@ -215,9 +222,15 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if pack_transfer.is_dest_location_to_confirm(move, scanned_location):
if confirmation:
- # keep the move in sync otherwise we would have a move line outside
- # the dest location of the move
- move.location_dest_id = scanned_location.id
+ # If the destination of the move would be incoherent
+ # (move line outside of it), we change the moves' destination
+ if not self.env["stock.location"].search_count(
+ [
+ ("id", "child_of", move.location_dest_id.id),
+ ("id", "=", scanned_location.id),
+ ]
+ ):
+ move.location_dest_id = scanned_location.id
else:
return self._response_for_location_need_confirm()
@@ -255,6 +268,7 @@ def _validator_validate(self):
return {
"package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
"location_barcode": {"type": "string", "nullable": False, "required": True},
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
}
def _validator_return_validate(self):
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 9e879f3fc1..db2576d372 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -36,6 +36,7 @@ def setUpClass(cls, *args, **kwargs):
}
)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck")
+ cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
@@ -100,7 +101,147 @@ def test_start(self):
},
)
+ def test_start_no_package_for_barcode(self):
+ """Test /start when no package is found for barcode
+
+ The pre-conditions:
+
+ * No Pack exists with the barcode
+
+ Expected result:
+
+ * return a message
+ """
+ params = {"barcode": "NOTHING_SHOULD_EXIST_WITH: 👀"}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "title": self.ANY,
+ "message_type": "error",
+ "message": "The package NOTHING_SHOULD_EXIST_WITH: 👀 doesn't exist",
+ },
+ )
+
+ def test_start_package_not_in_src_location(self):
+ """Test /start when the package is not in the src location
+
+ The pre-conditions:
+
+ * Pack exists with the barcode
+ * The Pack is outside the location or sublocation of the source
+ location of the current process' picking type
+
+ Expected result:
+
+ * return a message
+ """
+ barcode = self.packA.name
+ self.packA.location_id = self.shelf1
+ params = {"barcode": barcode}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "title": self.ANY,
+ "message_type": "error",
+ "message": "You cannot work on a package (%s) outside of location: %s"
+ % (
+ self.packA.name,
+ self.process.picking_type_ids.default_location_src_id.name,
+ ),
+ },
+ )
+
+ def test_start_move_in_different_picking_type(self):
+ """Test /start when the package is used in a move in a different picking type
+
+ The pre-conditions:
+
+ * Pack exists
+ * A move is created and assigned to move the package, using another picking type
+
+ Expected result:
+
+ * return a message
+ """
+ barcode = self.packA.name
+
+ # Create a move in a different picking type (trick the 'Delivery
+ # Orders' to go directly from Input to Customers)
+ picking_form = Form(self.env["stock.picking"])
+ picking_form.picking_type_id = self.wh.out_type_id
+ picking_form.location_id = self.input_location
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = self.productA
+ move.product_uom_qty = 1
+ picking = picking_form.save()
+ picking.action_confirm()
+ picking.action_assign()
+
+ params = {"barcode": barcode}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "title": self.ANY,
+ "message_type": "error",
+ "message": "An operation exists in Delivery Orders %s. You cannot"
+ " process it with this shopfloor process." % (picking.name,),
+ },
+ )
+
+ def test_start_move_in_same_picking_type(self):
+ """Test /start when the package is used in a move in the same picking type
+
+ The pre-conditions:
+
+ * Pack exists
+ * A move is created and assigned to move the package, using the same
+ picking type
+
+ Expected result:
+
+ * return a message
+ """
+ barcode = self.packA.name
+
+ # Create a move in a the same picking type
+ package_level = self._simulate_started()
+ move = package_level.move_line_ids.move_id
+
+ params = {"barcode": barcode}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ response,
+ state="confirm_start",
+ message={
+ "title": self.ANY,
+ "message_type": "warning",
+ "message": "Operation's already running."
+ " Would you like to take it over?",
+ },
+ data={
+ "id": self.ANY,
+ "location_src": {
+ "id": self.dispatch_location.id,
+ "name": self.dispatch_location.name,
+ },
+ "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name},
+ "name": package_level.package_id.name,
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ "product": {"id": move.product_id.id, "name": move.product_id.name},
+ },
+ )
+
def _simulate_started(self):
+ """Replicate what the /start endpoint would do
+
+ Used to test the next endpoints (/validate and /cancel)
+ """
picking_form = Form(self.env["stock.picking"])
picking_form.picking_type_id = self.menu.process_id.picking_type_ids
with picking_form.move_ids_without_package.new() as move:
@@ -252,6 +393,99 @@ def test_validate_location_forbidden(self):
},
)
+ def test_validate_location_to_confirm(self):
+ """Test a call on /validate on a location to confirm
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * No change in odoo, transition with a message
+
+ Note: a location to confirm is when a location is a child
+ of the destination location of the picking type used for the process
+ but not a child or the expected destination
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ # expected destination is 'shelf1', we'll scan shelf2 which must
+ # ask a confirmation to the user (it's still in the same picking type)
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ },
+ )
+
+ self.assert_response(
+ response,
+ state="confirm_location",
+ message={
+ "title": self.ANY,
+ "message_type": "warning",
+ "message": "Are you sure?",
+ },
+ )
+
+ def test_validate_location_with_confirm(self):
+ """Test a call on /validate on a different location with confirmation
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * Ignore the fact that the scanned location is not the expected
+ * Change the destination of the move line to the scanned one
+ * The move associated to the package level is 'done'
+
+ Note: a location to confirm is when a location is a child
+ of the destination location of the picking type used for the process
+ but not a child or the expected destination.
+ In such situation, the js application has to call /validate with
+ a ``confirmation`` flag.
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ # expected destination is 'shelf1', we'll scan shelf2 which must
+ # ask a confirmation to the user (it's still in the same picking type)
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ # acknowledge the change of destination
+ "confirmation": True,
+ },
+ )
+
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "title": self.ANY,
+ "message_type": "info",
+ "message": "The pack has been moved, you can scan a new pack.",
+ },
+ )
+
+ self.assertRecordValues(
+ package_level.move_line_ids,
+ [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}],
+ )
+ self.assertRecordValues(
+ package_level.move_line_ids.move_id,
+ [{"location_dest_id": self.stock_location.id, "state": "done"}],
+ )
+
def test_cancel(self):
"""Test the happy path for single pack putaway /cancel endpoint
From 40dc31d3c73c91e0dd12f72b96aa82283289b06e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 6 Feb 2020 17:20:21 +0100
Subject: [PATCH 068/940] Rename camel-cased variables
---
shopfloor/tests/test_single_pack_putaway.py | 28 ++++++++++-----------
1 file changed, 14 insertions(+), 14 deletions(-)
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index db2576d372..3eb9f3d1e7 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -14,25 +14,25 @@ def setUpClass(cls, *args, **kwargs):
cls.input_location = cls.env.ref("stock.stock_location_company")
cls.shelf1 = cls.env.ref("stock.stock_location_components")
cls.shelf2 = cls.env.ref("stock.stock_location_14")
- cls.productA = cls.env["product.product"].create(
+ cls.product_a = cls.env["product.product"].create(
{"name": "Product A", "type": "product"}
)
- cls.packA = cls.env["stock.quant.package"].create(
+ cls.pack_a = cls.env["stock.quant.package"].create(
{"location_id": stock_location.id}
)
cls.env["stock.putaway.rule"].create(
{
- "product_id": cls.productA.id,
+ "product_id": cls.product_a.id,
"location_in_id": cls.stock_location.id,
"location_out_id": cls.shelf1.id,
}
)
cls.quantA = cls.env["stock.quant"].create(
{
- "product_id": cls.productA.id,
+ "product_id": cls.product_a.id,
"location_id": cls.dispatch_location.id,
"quantity": 1,
- "package_id": cls.packA.id,
+ "package_id": cls.pack_a.id,
}
)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck")
@@ -62,7 +62,7 @@ def test_start(self):
The next step in the workflow is to call /validate with the created
package level that will set the move and picking to done.
"""
- barcode = self.packA.name
+ barcode = self.pack_a.name
params = {"barcode": barcode}
# Simulate the client scanning a package's barcode, which
# in turns should start the operation in odoo
@@ -137,8 +137,8 @@ def test_start_package_not_in_src_location(self):
* return a message
"""
- barcode = self.packA.name
- self.packA.location_id = self.shelf1
+ barcode = self.pack_a.name
+ self.pack_a.location_id = self.shelf1
params = {"barcode": barcode}
response = self.service.dispatch("start", params=params)
self.assert_response(
@@ -149,7 +149,7 @@ def test_start_package_not_in_src_location(self):
"message_type": "error",
"message": "You cannot work on a package (%s) outside of location: %s"
% (
- self.packA.name,
+ self.pack_a.name,
self.process.picking_type_ids.default_location_src_id.name,
),
},
@@ -167,7 +167,7 @@ def test_start_move_in_different_picking_type(self):
* return a message
"""
- barcode = self.packA.name
+ barcode = self.pack_a.name
# Create a move in a different picking type (trick the 'Delivery
# Orders' to go directly from Input to Customers)
@@ -175,7 +175,7 @@ def test_start_move_in_different_picking_type(self):
picking_form.picking_type_id = self.wh.out_type_id
picking_form.location_id = self.input_location
with picking_form.move_ids_without_package.new() as move:
- move.product_id = self.productA
+ move.product_id = self.product_a
move.product_uom_qty = 1
picking = picking_form.save()
picking.action_confirm()
@@ -207,7 +207,7 @@ def test_start_move_in_same_picking_type(self):
* return a message
"""
- barcode = self.packA.name
+ barcode = self.pack_a.name
# Create a move in a the same picking type
package_level = self._simulate_started()
@@ -245,13 +245,13 @@ def _simulate_started(self):
picking_form = Form(self.env["stock.picking"])
picking_form.picking_type_id = self.menu.process_id.picking_type_ids
with picking_form.move_ids_without_package.new() as move:
- move.product_id = self.productA
+ move.product_id = self.product_a
move.product_uom_qty = 1
picking = picking_form.save()
picking.action_confirm()
picking.action_assign()
package_level = picking.move_line_ids.package_level_id
- self.assertEqual(package_level.package_id, self.packA)
+ self.assertEqual(package_level.package_id, self.pack_a)
# at this point, the package level is already set to "done", by the
# "start" method of the pack transfer putaway
package_level.is_done = True
From af1c5896832d6faf71328605e449fc7e12d9486a Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 6 Feb 2020 17:23:20 +0100
Subject: [PATCH 069/940] Remove title from messages
The message and kind for styling are enough. Title is often a bit random
and we do not have much room on the UI anyway.
---
shopfloor/actions/message.py | 39 +++------------------
shopfloor/services/service.py | 3 +-
shopfloor/services/single_pack_putaway.py | 1 -
shopfloor/tests/common.py | 1 -
shopfloor/tests/test_single_pack_putaway.py | 30 ++--------------
5 files changed, 8 insertions(+), 66 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index d008b58d8d..3d1a528dfb 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -2,8 +2,6 @@
from odoo.addons.component.core import Component
-# TODO remove 'title', useless
-
class MessageAction(Component):
"""Provide message templates
@@ -22,28 +20,24 @@ class MessageAction(Component):
def no_picking_type(self):
return {
"message_type": "error",
- "title": _("Configuration error"),
"message": _("No operation type found for this menu and profile."),
}
def several_picking_types(self):
return {
"message_type": "error",
- "title": _("Configuration error"),
"message": _("Several operation types found for this menu and profile."),
}
def package_not_found_for_barcode(self, barcode):
return {
"message_type": "error",
- "title": _("Package not found"),
"message": _("The package %s doesn't exist") % barcode,
}
def package_not_allowed_in_src_location(self, barcode, picking_type):
return {
"message_type": "error",
- "title": _("Cannot proceed"),
"message": _("You cannot work on a package (%s) outside of location: %s")
% (barcode, picking_type.default_location_src_id.name),
}
@@ -51,86 +45,62 @@ def package_not_allowed_in_src_location(self, barcode, picking_type):
def already_running_ask_confirmation(self):
return {
"message_type": "warning",
- "title": _("Already started"),
"message": _(
"Operation's already running. Would you like to take it over?"
),
}
def scan_destination(self):
- return {
- "message_type": "info",
- "title": _("Scan"),
- "message": _("Scan the destination location"),
- }
+ return {"message_type": "info", "message": _("Scan the destination location")}
def operation_not_found(self):
return {
"message_type": "error",
- "title": _("Start again"),
"message": _("This operation does not exist anymore."),
}
def operation_has_been_canceled_elsewhere(self):
return {
"message_type": "warning",
- "title": _("Restart"),
"message": _("Restart the operation, someone has canceled it."),
}
def no_location_found(self):
return {
"message_type": "error",
- "title": _("Scan"),
"message": _("No location found for this barcode."),
}
def dest_location_not_allowed(self):
- return {
- "message_type": "error",
- "title": _("Forbidden"),
- "message": _("You cannot place it here"),
- }
+ return {"message_type": "error", "message": _("You cannot place it here")}
def need_confirmation(self):
- return {
- "message_type": "warning",
- "title": _("Confirm"),
- "message": _("Are you sure?"),
- }
+ return {"message_type": "warning", "message": _("Are you sure?")}
def confirm_pack_moved(self):
return {
"message_type": "info",
- "title": _("Start"),
"message": _("The pack has been moved, you can scan a new pack."),
}
def already_done(self):
- return {
- "message_type": "info",
- "title": _("Continue"),
- "message": _("Operation already processed."),
- }
+ return {"message_type": "info", "message": _("Operation already processed.")}
def confirm_canceled_scan_next_pack(self):
return {
"message_type": "info",
- "title": _("Continue"),
"message": _("Canceled, you can scan a new pack."),
}
def no_pack_in_location(self, location):
return {
"message_type": "error",
- "title": _("Scan package"),
"message": _("Location %s doesn’t contain any package." % location.name),
}
def several_packs_in_location(self, location):
return {
"message_type": "error",
- "title": _("Scan package"),
"message": _(
"Several PACKs found in %s, please scan one PACK." % location.name
),
@@ -139,6 +109,5 @@ def several_packs_in_location(self, location):
def no_pending_operation_for_pack(self, pack):
return {
"message_type": "error",
- "title": _("Error"),
"message": _("No pending operation for package %s." % pack.name),
}
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index bfed200be3..aa5f300e72 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -89,8 +89,7 @@ def _response_schema(self, data_schema=None):
"required": True,
"allowed": ["info", "warning", "error"],
},
- "title": {"type": "string", "required": False},
- "body": {"type": "string", "required": True},
+ "message": {"type": "string", "required": True},
},
},
}
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 4325df07b5..6fc36be2f8 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -40,7 +40,6 @@ def _response_for_forbidden_start(self, existing_operations):
state="start",
message={
"message_type": "error",
- "title": _("Cannot proceed"),
"message": _(
"An operation exists in %s %s. "
"You cannot process it with this shopfloor process."
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 372fbd187c..682656bab2 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -80,7 +80,6 @@ def assert_dict(self, current, expected):
{
"data": self.ANY,
"message": {
- "title": self.ANY,
"message_type": self.ANY,
"message": self.ANY,
},
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 3eb9f3d1e7..d2ccc6c6fb 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -84,7 +84,6 @@ def test_start(self):
response,
state="scan_location",
message={
- "title": self.ANY,
"message_type": "info",
"message": "Scan the destination location",
},
@@ -118,7 +117,6 @@ def test_start_no_package_for_barcode(self):
response,
state="start",
message={
- "title": self.ANY,
"message_type": "error",
"message": "The package NOTHING_SHOULD_EXIST_WITH: 👀 doesn't exist",
},
@@ -145,7 +143,6 @@ def test_start_package_not_in_src_location(self):
response,
state="start",
message={
- "title": self.ANY,
"message_type": "error",
"message": "You cannot work on a package (%s) outside of location: %s"
% (
@@ -187,7 +184,6 @@ def test_start_move_in_different_picking_type(self):
response,
state="start",
message={
- "title": self.ANY,
"message_type": "error",
"message": "An operation exists in Delivery Orders %s. You cannot"
" process it with this shopfloor process." % (picking.name,),
@@ -219,7 +215,6 @@ def test_start_move_in_same_picking_type(self):
response,
state="confirm_start",
message={
- "title": self.ANY,
"message_type": "warning",
"message": "Operation's already running."
" Would you like to take it over?",
@@ -286,7 +281,6 @@ def test_validate(self):
response,
state="start",
message={
- "title": self.ANY,
"message_type": "info",
"message": "The pack has been moved, you can scan a new pack.",
},
@@ -317,7 +311,6 @@ def test_validate_not_found(self):
response,
state="start",
message={
- "title": self.ANY,
"message_type": "error",
"message": "This operation does not exist anymore.",
},
@@ -350,7 +343,6 @@ def test_validate_location_not_found(self):
response,
state="scan_location",
message={
- "title": self.ANY,
"message_type": "error",
"message": "No location found for this barcode.",
},
@@ -386,11 +378,7 @@ def test_validate_location_forbidden(self):
self.assert_response(
response,
state="scan_location",
- message={
- "title": self.ANY,
- "message_type": "error",
- "message": "You cannot place it here",
- },
+ message={"message_type": "error", "message": "You cannot place it here"},
)
def test_validate_location_to_confirm(self):
@@ -425,11 +413,7 @@ def test_validate_location_to_confirm(self):
self.assert_response(
response,
state="confirm_location",
- message={
- "title": self.ANY,
- "message_type": "warning",
- "message": "Are you sure?",
- },
+ message={"message_type": "warning", "message": "Are you sure?"},
)
def test_validate_location_with_confirm(self):
@@ -471,7 +455,6 @@ def test_validate_location_with_confirm(self):
response,
state="start",
message={
- "title": self.ANY,
"message_type": "info",
"message": "The pack has been moved, you can scan a new pack.",
},
@@ -519,7 +502,6 @@ def test_cancel(self):
response,
state="start",
message={
- "title": self.ANY,
"message_type": "info",
"message": "Canceled, you can scan a new pack.",
},
@@ -562,7 +544,6 @@ def test_cancel_already_canceled(self):
response,
state="start",
message={
- "title": self.ANY,
"message_type": "info",
"message": "Canceled, you can scan a new pack.",
},
@@ -601,11 +582,7 @@ def test_cancel_already_done(self):
self.assert_response(
response,
state="start",
- message={
- "title": self.ANY,
- "message_type": "info",
- "message": "Operation already processed.",
- },
+ message={"message_type": "info", "message": "Operation already processed."},
)
def test_cancel_not_found(self):
@@ -620,7 +597,6 @@ def test_cancel_not_found(self):
response,
state="start",
message={
- "title": self.ANY,
"message_type": "error",
"message": "This operation does not exist anymore.",
},
From 2da21d69e03c57b1bcc47c2ce3e457a6ea40d70b Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 7 Feb 2020 08:29:10 +0100
Subject: [PATCH 070/940] Add method to check if location is a sublocation of
another
---
shopfloor/actions/pack_transfer_validate.py | 21 +++------------------
shopfloor/models/__init__.py | 1 +
shopfloor/models/stock_location.py | 13 +++++++++++++
shopfloor/services/single_pack_putaway.py | 17 ++---------------
4 files changed, 19 insertions(+), 33 deletions(-)
create mode 100644 shopfloor/models/stock_location.py
diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py
index 4997e402ef..a53998e748 100644
--- a/shopfloor/actions/pack_transfer_validate.py
+++ b/shopfloor/actions/pack_transfer_validate.py
@@ -18,31 +18,16 @@ class PackTransferValidateAction(Component):
def is_move_state_valid(self, move):
return move.state != "cancel"
- # TODO generic method "is_location_below"
def is_dest_location_valid(self, move, scanned_location):
"""Forbid a dest location to be used"""
- allowed_location = self.env["stock.location"].search_count(
- [
- (
- "id",
- "child_of",
- move.picking_id.picking_type_id.default_location_dest_id.id,
- ),
- ("id", "=", scanned_location.id),
- ]
+ return scanned_location.is_sublocation_of(
+ move.picking_id.picking_type_id.default_location_dest_id
)
- return bool(allowed_location)
def is_dest_location_to_confirm(self, move, scanned_location):
"""Destination that could be used but need confirmation"""
move_dest_location = move.move_line_ids[0].location_dest_id
- in_dest_location = self.env["stock.location"].search_count(
- [
- ("id", "child_of", move_dest_location.id),
- ("id", "=", scanned_location.id),
- ]
- )
- return not bool(in_dest_location)
+ return not scanned_location.is_sublocation_of(move_dest_location)
def set_destination_and_done(self, move, scanned_location):
move.move_line_ids[0].location_dest_id = scanned_location.id
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index 1aa3c9fbc8..d0299a28b9 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -3,4 +3,5 @@
from . import shopfloor_process
from . import stock_picking_type
from . import shopfloor_profile
+from . import stock_location
from . import res_users
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
new file mode 100644
index 0000000000..6ff8d3a4e8
--- /dev/null
+++ b/shopfloor/models/stock_location.py
@@ -0,0 +1,13 @@
+from odoo import models
+
+
+class StockLocation(models.Model):
+ _inherit = "stock.location"
+
+ def is_sublocation_of(self, other):
+ self.ensure_one()
+ return bool(
+ self.env["stock.location"].search_count(
+ [("id", "child_of", other.id), ("id", "=", self.id)]
+ )
+ )
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 6fc36be2f8..c5f2df3a7a 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -96,15 +96,7 @@ def start(self, barcode):
return self._response_for_package_not_found(barcode)
assert len(pack) == 1, "We cannot have 2 packages with the same barcode"
- # TODO this seems to be a pretty common check, consider moving
- # it to an Action Component
- allowed_location = self.env["stock.location"].search_count(
- [
- ("id", "child_of", picking_type.default_location_src_id.id),
- ("id", "=", pack.location_id.id),
- ]
- )
- if not allowed_location:
+ if not pack.location_id.is_sublocation_of(picking_type.default_location_src_id):
return self._response_for_forbidden_package(barcode, picking_type)
existing_operation = self.env["stock.move.line"].search(
@@ -223,12 +215,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if confirmation:
# If the destination of the move would be incoherent
# (move line outside of it), we change the moves' destination
- if not self.env["stock.location"].search_count(
- [
- ("id", "child_of", move.location_dest_id.id),
- ("id", "=", scanned_location.id),
- ]
- ):
+ if not scanned_location.is_sublocation_of(move.location_dest_id):
move.location_dest_id = scanned_location.id
else:
return self._response_for_location_need_confirm()
From 39836ed78efe4e340be4820f6f2be5d958849003 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 7 Feb 2020 14:40:00 +0100
Subject: [PATCH 071/940] Add single putaway methods and tests
The "completion info" state still needs to be added.
---
shopfloor/actions/message.py | 4 +-
shopfloor/actions/pack_transfer_validate.py | 2 +-
shopfloor/demo/stock_picking_type_demo.xml | 2 +-
shopfloor/services/single_pack_putaway.py | 9 +-
shopfloor/services/single_pack_transfer.py | 130 ++--
shopfloor/tests/common.py | 11 +
shopfloor/tests/test_single_pack_putaway.py | 23 +-
shopfloor/tests/test_single_pack_transfer.py | 677 +++++++++++++++++++
8 files changed, 796 insertions(+), 62 deletions(-)
create mode 100644 shopfloor/tests/test_single_pack_transfer.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 3d1a528dfb..98557b39cd 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -95,14 +95,14 @@ def confirm_canceled_scan_next_pack(self):
def no_pack_in_location(self, location):
return {
"message_type": "error",
- "message": _("Location %s doesn’t contain any package." % location.name),
+ "message": _("Location %s doesn't contain any package." % location.name),
}
def several_packs_in_location(self, location):
return {
"message_type": "error",
"message": _(
- "Several PACKs found in %s, please scan one PACK." % location.name
+ "Several packages found in %s, please scan a package." % location.name
),
}
diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py
index a53998e748..7de53656cd 100644
--- a/shopfloor/actions/pack_transfer_validate.py
+++ b/shopfloor/actions/pack_transfer_validate.py
@@ -1,7 +1,7 @@
from odoo.addons.component.core import Component
-# TODO think about how we want to share the common methods / workflows
+# TODO think BETTER about how we want to share the common methods / workflows
class PackTransferValidateAction(Component):
"""Pack Transfer shared business logic
diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml
index c6bb3bae22..6e473b5de6 100644
--- a/shopfloor/demo/stock_picking_type_demo.xml
+++ b/shopfloor/demo/stock_picking_type_demo.xml
@@ -21,7 +21,7 @@
SPT
-
+
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index c5f2df3a7a..b2f9294052 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -51,7 +51,7 @@ def _response_for_forbidden_start(self, existing_operations):
},
)
- def _data_for_scan(self, move_line, pack):
+ def _data_after_package_scanned(self, move_line, pack):
move = move_line.move_id
return {
"id": move_line.package_level_id.id,
@@ -68,7 +68,7 @@ def _data_for_scan(self, move_line, pack):
def _response_for_start_to_confirm(self, move_line, pack):
message = self.actions_for("message")
return self._response(
- data=self._data_for_scan(move_line, pack),
+ data=self._data_after_package_scanned(move_line, pack),
state="confirm_start",
message=message.already_running_ask_confirmation(),
)
@@ -78,7 +78,7 @@ def _response_for_start_success(self, move_line, pack):
return self._response(
state="scan_location",
message=message.scan_destination(),
- data=self._data_for_scan(move_line, pack),
+ data=self._data_after_package_scanned(move_line, pack),
)
def start(self, barcode):
@@ -250,6 +250,9 @@ def _validator_cancel(self):
"package_level_id": {"coerce": to_int, "required": True, "type": "integer"}
}
+ def _validator_return_cancel(self):
+ return self._response_schema()
+
def _validator_validate(self):
return {
"package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 1e26827324..5c242a075b 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -1,6 +1,8 @@
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
+# TODO add completion info screen
+
class SinglePackTransfer(Component):
"""Methods for the Single Pack Transfer Process"""
@@ -10,6 +12,8 @@ class SinglePackTransfer(Component):
_usage = "single_pack_transfer"
_description = __doc__
+ # TODO get rid of these methods now that we have a component
+ # for the messages? could help for extensibility though...?
def _response_for_empty_location(self, location):
message = self.actions_for("message")
return self._response(
@@ -28,58 +32,60 @@ def _response_for_package_not_found(self, barcode):
state="start", message=message.package_not_found_for_barcode(barcode)
)
+ def _response_for_forbidden_package(self, barcode, picking_type):
+ message = self.actions_for("message")
+ return self._response(
+ state="start",
+ message=message.package_not_allowed_in_src_location(barcode, picking_type),
+ )
+
+ def _response_for_several_picking_types(self):
+ message = self.actions_for("message")
+ return self._response(state="start", message=message.several_picking_types())
+
def _response_for_operation_not_found(self, pack):
message = self.actions_for("message")
return self._response(
state="start", message=message.no_pending_operation_for_pack(pack)
)
- def _response_for_start_to_confirm(self, existing_operations, pack):
+ def _data_after_package_scanned(self, move_line, pack):
+ move = move_line.move_id
+ return {
+ "id": move_line.package_level_id.id,
+ "name": pack.name,
+ "location_src": {"id": pack.location_id.id, "name": pack.location_id.name},
+ "location_dst": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ "product": {"id": move.product_id.id, "name": move.product_id.name},
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ }
+
+ def _response_for_start_to_confirm(self, move_line, pack):
message = self.actions_for("message")
- move = existing_operations.move_id
return self._response(
- data={
- "id": existing_operations[0].package_level_id.id,
- "name": pack.name,
- "location_src": {
- "id": pack.location_id.id,
- "name": pack.location_id.name,
- },
- "location_dst": {
- "id": existing_operations[0].location_dest_id.id,
- "name": existing_operations[0].location_dest_id.name,
- },
- "product": {"id": move.product_id.name, "name": move.product_id.name},
- "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
- },
state="confirm_start",
message=message.already_running_ask_confirmation(),
+ data=self._data_after_package_scanned(move_line, pack),
)
- def _response_for_start_success(self, move, pack):
+ def _response_for_start_success(self, move_line, pack):
message = self.actions_for("message")
return self._response(
state="scan_location",
message=message.scan_destination(),
- data={
- "id": move.move_line_ids[0].package_level_id.id,
- "name": pack.name,
- "location_src": {
- "id": pack.location_id.id,
- "name": pack.location_id.name,
- },
- "location_dst": {
- "id": move.move_line_ids[0].location_dest_id.id,
- "name": move.move_line_ids[0].location_dest_id.name,
- },
- "product": {"id": move.product_id.id, "name": move.product_id.name},
- "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
- },
+ data=self._data_after_package_scanned(move_line, pack),
)
def start(self, barcode):
search = self.actions_for("search")
+ picking_type = self.picking_types
+ if len(picking_type) > 1:
+ return self._response_for_several_picking_types()
+
location = search.location_from_scan(barcode)
pack = self.env["stock.quant.package"]
@@ -90,7 +96,7 @@ def start(self, barcode):
if not pack:
return self._response_for_empty_location(location)
if len(pack) > 1:
- return self._response_for_several_packages(self, location)
+ return self._response_for_several_packages(location)
if not pack:
pack = search.package_from_scan(barcode)
@@ -98,20 +104,24 @@ def start(self, barcode):
if not pack:
return self._response_for_package_not_found(barcode)
+ if not pack.location_id.is_sublocation_of(picking_type.default_location_src_id):
+ return self._response_for_forbidden_package(barcode, picking_type)
+
existing_operations = self.env["stock.move.line"].search(
[
("package_id", "=", pack.id),
+ ("state", "!=", "done"),
("picking_id.picking_type_id", "in", self.picking_types.ids),
]
)
if not existing_operations:
return self._response_for_operation_not_found(pack)
- move = existing_operations.move_id
+ # TODO can we have more than one move line?
if existing_operations[0].package_level_id.is_done:
return self._response_for_start_to_confirm(existing_operations, pack)
existing_operations[0].package_level_id.is_done = True
- return self._response_for_start_success(move, pack)
+ return self._response_for_start_success(existing_operations[0], pack)
def _validator_start(self):
return {"barcode": {"type": "string", "nullable": False, "required": True}}
@@ -156,12 +166,18 @@ def _response_for_package_level_not_found(self):
message = self.actions_for("message")
return self._response(state="start", message=message.operation_not_found())
- def _response_for_move_canceled(self):
+ def _response_for_move_canceled_elsewhere(self):
message = self.actions_for("message")
return self._response(
state="start", message=message.operation_has_been_canceled_elsewhere()
)
+ def _response_for_location_not_found(self):
+ message = self.actions_for("message")
+ return self._response(
+ state="scan_location", message=message.no_location_found()
+ )
+
def _response_for_forbidden_location(self):
message = self.actions_for("message")
return self._response(
@@ -180,6 +196,7 @@ def _response_for_validate_success(self):
def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
+ # TODO this method is duplicated in putaway
pack_transfer = self.actions_for("pack.transfer.validate")
search = self.actions_for("search")
@@ -189,21 +206,24 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
move = package.move_line_ids[0].move_id
if not pack_transfer.is_move_state_valid(move):
- return self._response_for_move_canceled()
+ return self._response_for_move_canceled_elsewhere()
scanned_location = search.location_from_scan(location_barcode)
+ if not scanned_location:
+ return self._response_for_location_not_found()
if not pack_transfer.is_dest_location_valid(move, scanned_location):
return self._response_for_forbidden_location()
if pack_transfer.is_dest_location_to_confirm(move, scanned_location):
if confirmation:
- # keep the move in sync otherwise we would have a move line outside
- # the dest location of the move
- move.location_dest_id = scanned_location.id
+ # If the destination of the move would be incoherent
+ # (move line outside of it), we change the moves' destination
+ if not scanned_location.is_sublocation_of(move.location_dest_id):
+ move.location_dest_id = scanned_location.id
else:
return self._response_for_location_need_confirm()
- pack_transfer.set_destination_and_done()
+ pack_transfer.set_destination_and_done(move, scanned_location)
return self._response_for_validate_success()
def _validator_validate(self):
@@ -216,4 +236,32 @@ def _validator_validate(self):
def _validator_return_validate(self):
return self._response_schema()
- # TODO cancel
+ def cancel(self, package_level_id):
+ package = self.env["stock.package_level"].browse(package_level_id)
+ if not package.exists():
+ return self._response_for_package_level_not_found()
+ # package.move_ids may be empty, it seems
+ move = package.move_line_ids.move_id
+ if move.state == "done":
+ return self._response_for_move_already_processed()
+
+ package.is_done = False
+ return self._response_for_confirm_cancel()
+
+ def _response_for_move_already_processed(self):
+ message = self.actions_for("message")
+ return self._response(state="start", message=message.already_done())
+
+ def _response_for_confirm_cancel(self):
+ message = self.actions_for("message")
+ return self._response(
+ state="start", message=message.confirm_canceled_scan_next_pack()
+ )
+
+ def _validator_cancel(self):
+ return {
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}
+ }
+
+ def _validator_return_cancel(self):
+ return self._response_schema()
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 682656bab2..8b58279f0d 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -49,6 +49,17 @@ def setUpClass(cls):
context=dict(cls.env.context, tracking_disable=cls.tracking_disable)
)
cls.setUpComponent()
+ cls.setUpClassVars()
+
+ @classmethod
+ def setUpClassVars(cls):
+ stock_location = cls.env.ref("stock.stock_location_stock")
+ cls.stock_location = stock_location
+ cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone")
+ cls.dispatch_location.barcode = "DISPATCH"
+ cls.input_location = cls.env.ref("stock.stock_location_company")
+ cls.shelf1 = cls.env.ref("stock.stock_location_components")
+ cls.shelf2 = cls.env.ref("stock.stock_location_14")
def assert_response(self, response, state=None, message=None, data=None):
"""Assert a response from the webservice
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index d2ccc6c6fb..2983419727 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -3,22 +3,15 @@
from .common import CommonCase
-class PutawayCase(CommonCase):
+class SinglePackPutawayCase(CommonCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
- stock_location = cls.env.ref("stock.stock_location_stock")
- cls.stock_location = stock_location
- cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone")
- cls.dispatch_location.barcode = "DISPATCH"
- cls.input_location = cls.env.ref("stock.stock_location_company")
- cls.shelf1 = cls.env.ref("stock.stock_location_components")
- cls.shelf2 = cls.env.ref("stock.stock_location_14")
cls.product_a = cls.env["product.product"].create(
{"name": "Product A", "type": "product"}
)
cls.pack_a = cls.env["stock.quant.package"].create(
- {"location_id": stock_location.id}
+ {"location_id": cls.stock_location.id}
)
cls.env["stock.putaway.rule"].create(
{
@@ -27,7 +20,7 @@ def setUpClass(cls, *args, **kwargs):
"location_out_id": cls.shelf1.id,
}
)
- cls.quantA = cls.env["stock.quant"].create(
+ cls.quant_a = cls.env["stock.quant"].create(
{
"product_id": cls.product_a.id,
"location_id": cls.dispatch_location.id,
@@ -190,8 +183,10 @@ def test_start_move_in_different_picking_type(self):
},
)
- def test_start_move_in_same_picking_type(self):
- """Test /start when the package is used in a move in the same picking type
+ def test_start_move_already_exist(self):
+ """Test /start when the move for the package already exists
+
+ Because it was already started.
The pre-conditions:
@@ -201,7 +196,7 @@ def test_start_move_in_same_picking_type(self):
Expected result:
- * return a message
+ * return a message to confirm
"""
barcode = self.pack_a.name
@@ -228,7 +223,7 @@ def test_start_move_in_same_picking_type(self):
"location_dst": {"id": self.shelf1.id, "name": self.shelf1.name},
"name": package_level.package_id.name,
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
- "product": {"id": move.product_id.id, "name": move.product_id.name},
+ "product": {"id": self.product_a.id, "name": self.product_a.name},
},
)
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
new file mode 100644
index 0000000000..df30c61845
--- /dev/null
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -0,0 +1,677 @@
+from odoo.tests.common import Form
+
+from .common import CommonCase
+
+
+class SinglePackTransferCase(CommonCase):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.product_a = cls.env["product.product"].create(
+ {"name": "Product A", "type": "product"}
+ )
+ cls.pack_a = cls.env["stock.quant.package"].create(
+ {"location_id": cls.stock_location.id}
+ )
+ cls.quant_a = cls.env["stock.quant"].create(
+ {
+ "product_id": cls.product_a.id,
+ "location_id": cls.shelf1.id,
+ "quantity": 1,
+ "package_id": cls.pack_a.id,
+ }
+ )
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_single_pallet_transfer")
+ cls.process = cls.menu.process_id
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.picking_type = cls.process.picking_type_ids
+ cls.picking = cls._create_initial_move()
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
+ self.service = work.component(usage="single_pack_transfer")
+
+ @classmethod
+ def _create_initial_move(cls):
+ """Create the move to satisfy the pre-condition before /start"""
+ picking_form = Form(cls.env["stock.picking"])
+ picking_form.picking_type_id = cls.picking_type
+ picking_form.location_id = cls.shelf1
+ picking_form.location_dest_id = cls.shelf2
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = cls.product_a
+ move.product_uom_qty = 1
+ picking = picking_form.save()
+ picking.action_confirm()
+ picking.action_assign()
+ return picking
+
+ def _simulate_started(self):
+ """Replicate what the /start endpoint would do
+
+ Used to test the next endpoints (/validate and /cancel)
+ """
+ package_level = self.picking.move_line_ids.package_level_id
+ package_level.is_done = True
+ return package_level
+
+ def test_start(self):
+ """Test the happy path for single pack transfer /start endpoint
+
+ We scan the barcode of the pack (simplest use case).
+
+ The pre-conditions:
+
+ * A Pack exists in Stock/Shelf1.
+ * A stock picking exists to move the Pack from Stock/Shelf1 to
+ Stock/Shelf2. The move is "assigned".
+
+ Expected result:
+
+ * The package level of the move is set to "is_done".
+
+ The next step in the workflow is to call /validate with the
+ package level that will set the move and picking to done.
+ """
+ barcode = self.pack_a.name
+ params = {"barcode": barcode}
+
+ package_level = self.picking.move_line_ids.package_level_id
+ self.assertFalse(package_level.is_done)
+
+ # Simulate the client scanning a package's barcode, which
+ # in turns should start the operation in odoo
+ response = self.service.dispatch("start", params=params)
+
+ self.assertTrue(package_level.is_done)
+ self.assert_response(
+ response,
+ state="scan_location",
+ message={
+ "message_type": "info",
+ "message": "Scan the destination location",
+ },
+ data={
+ "id": self.ANY,
+ "name": package_level.package_id.name,
+ "location_src": {"id": self.shelf1.id, "name": self.shelf1.name},
+ "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name},
+ "picking": {"id": self.picking.id, "name": self.picking.name},
+ "product": {"id": self.product_a.id, "name": self.product_a.name},
+ },
+ )
+
+ def test_start_no_operation(self):
+ """Test /start when there is no operation to move the pack
+
+ The pre-conditions:
+
+ * A Pack exists in Stock/Shelf1.
+ * No stock picking exists to move the Pack from Stock/Shelf1 to
+ Stock/Shelf2, or the state is not assigned.
+
+ Expected result:
+
+ * Return a message
+ """
+ barcode = self.pack_a.name
+ params = {"barcode": barcode}
+ self.picking.do_unreserve()
+
+ # Simulate the client scanning a package's barcode, which
+ # in turns should start the operation in odoo
+ response = self.service.dispatch("start", params=params)
+
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "message_type": "error",
+ "message": "No pending operation for package {}.".format(
+ self.pack_a.name
+ ),
+ },
+ )
+
+ def test_start_barcode_not_known(self):
+ """Test /start when the barcode is unknown
+
+ The pre-conditions:
+
+ * Nothing
+
+ Expected result:
+
+ * Return a message
+ """
+ params = {"barcode": "THIS_BARCODE_DOES_NOT_EXIST"}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "message_type": "error",
+ "message": "The package THIS_BARCODE_DOES_NOT_EXIST" " doesn't exist",
+ },
+ )
+
+ def test_start_pack_from_location(self):
+ """Test /start, finding the pack from location's barcode
+
+ When we scan a location which contains only one pack,
+ we want to move this pack.
+
+ The pre-conditions:
+
+ * A Pack exists in Stock/Shelf1.
+ * A stock picking exists to move the Pack from Stock/Shelf1 to
+ Stock/Shelf2. The move is "assigned".
+
+ Expected result:
+
+ * The package level of the move is set to "is_done".
+
+ The next step in the workflow is to call /validate with the
+ package level that will set the move and picking to done.
+ """
+ barcode = self.shelf1.barcode
+ params = {"barcode": barcode}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ # We only care about the fact that we jump to the next
+ # screen, so it found the pack. The details are already
+ # checked in the test_start test.
+ response,
+ state="scan_location",
+ message=self.ANY,
+ data=self.ANY,
+ )
+
+ def test_start_pack_from_location_empty(self):
+ """Test /start, scan location's barcode without pack
+
+ When we scan a location which contains no packs,
+ we ask the user to scan a pack.
+
+ The pre-conditions:
+
+ * No packs exists in Stock/Shelf2
+
+ Expected result:
+
+ * Return a message
+ """
+ barcode = self.shelf2.barcode
+ params = {"barcode": barcode}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "message_type": "error",
+ "message": "Location %s doesn't contain any package."
+ % (self.shelf2.name,),
+ },
+ )
+
+ def test_start_pack_from_location_several_packs(self):
+ """Test /start, scan location's barcode with several packs
+
+ When we scan a location which contains several packs,
+ we ask the user to scan a pack.
+
+ The pre-conditions:
+
+ * 2 packs exists in Stock/Shelf1.
+
+ Expected result:
+
+ * Return a message
+ """
+ pack_b = self.env["stock.quant.package"].create(
+ {"location_id": self.stock_location.id}
+ )
+ self.env["stock.quant"].create(
+ {
+ "product_id": self.product_a.id,
+ "location_id": self.shelf1.id,
+ "quantity": 1,
+ "package_id": pack_b.id,
+ }
+ )
+ barcode = self.shelf1.barcode
+ params = {"barcode": barcode}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "message_type": "error",
+ "message": "Several packages found in %s, please scan a package."
+ % (self.shelf1.name,),
+ },
+ )
+
+ def test_start_pack_outside_of_location(self):
+ """Test /start, scan a pack outside of the picking type location
+
+ The pre-conditions:
+
+ * A pack exists in a location outside of Stock (the default source
+ location of the picking type associated with the process)
+
+ Expected result:
+
+ * Return a message
+ """
+ self.pack_a.location_id = self.dispatch_location
+ barcode = self.pack_a.name
+ params = {"barcode": barcode}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "message_type": "error",
+ "message": "You cannot work on a package (%s) outside of location: %s"
+ % (self.pack_a.name, self.picking_type.default_location_src_id.name),
+ },
+ )
+
+ def test_start_already_started(self):
+ """Test /start when it was already started
+
+ We scan the barcode of the pack (simplest use case).
+
+ The pre-conditions:
+
+ * A Pack exists in Stock/Shelf1.
+ * A stock picking exists to move the Pack from Stock/Shelf1 to
+ Stock/Shelf2. The move is "assigned".
+ * Start is already called once
+
+ Expected result:
+
+ * Transition for confirmation with such message
+
+ The next step in the workflow is to call /validate with the
+ package level that will set the move and picking to done.
+ """
+ barcode = self.pack_a.name
+ params = {"barcode": barcode}
+
+ package_level = self._simulate_started()
+ self.assertTrue(package_level.is_done)
+
+ # Simulate the client scanning a package's barcode, which
+ # in turns should start the operation in odoo
+ response = self.service.dispatch("start", params=params)
+
+ self.assert_response(
+ response,
+ state="confirm_start",
+ message={
+ "message_type": "warning",
+ "message": "Operation's already running."
+ " Would you like to take it over?",
+ },
+ data={
+ "id": self.ANY,
+ "name": package_level.package_id.name,
+ "location_src": {"id": self.shelf1.id, "name": self.shelf1.name},
+ "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name},
+ "picking": {"id": self.picking.id, "name": self.picking.name},
+ "product": {"id": self.product_a.id, "name": self.product_a.name},
+ },
+ )
+
+ def test_validate(self):
+ """Test the happy path for single pack transfer /validate endpoint
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * The move associated to the package level is 'done'
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ # now, call the service to proceed with validation of the
+ # movement
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ },
+ )
+
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "message_type": "info",
+ "message": "The pack has been moved, you can scan a new pack.",
+ },
+ )
+
+ self.assertRecordValues(
+ package_level.move_line_ids,
+ [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}],
+ )
+ self.assertRecordValues(
+ package_level.move_line_ids.move_id,
+ [{"location_dest_id": self.shelf2.id, "state": "done"}],
+ )
+
+ def test_validate_not_found(self):
+ """Test a call on /validate on package level not found
+
+ Expected result:
+
+ * No change in odoo, Transition with a message
+ """
+ response = self.service.dispatch(
+ "validate",
+ params={"package_level_id": -1, "location_barcode": self.shelf1.barcode},
+ )
+
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "message_type": "error",
+ "message": "This operation does not exist anymore.",
+ },
+ )
+
+ def test_validate_location_not_found(self):
+ """Test a call on /validate on location not found
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * No change in odoo, Transition with a message
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": "THIS_BARCODE_DOES_NOT_EXISTS",
+ },
+ )
+
+ self.assert_response(
+ response,
+ state="scan_location",
+ message={
+ "message_type": "error",
+ "message": "No location found for this barcode.",
+ },
+ )
+
+ def test_validate_location_forbidden(self):
+ """Test a call on /validate on a forbidden location
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * No change in odoo, Transition with a message
+
+ Note: a forbidden location is when a location is not a child
+ of the destination location of the picking type used for the process
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ # this location is outside of the expected destination
+ "location_barcode": self.dispatch_location.barcode,
+ },
+ )
+
+ self.assert_response(
+ response,
+ state="scan_location",
+ message={"message_type": "error", "message": "You cannot place it here"},
+ )
+
+ def test_validate_location_to_confirm(self):
+ """Test a call on /validate on a location to confirm
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * No change in odoo, transition with a message
+
+ Note: a location to confirm is when a location is a child
+ of the destination location of the picking type used for the process
+ but not a child or the expected destination
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ # expected destination is 'shelf2', we'll scan shelf1 which must
+ # ask a confirmation to the user (it's still in the same picking type)
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf1.barcode,
+ },
+ )
+
+ self.assert_response(
+ response,
+ state="confirm_location",
+ message={"message_type": "warning", "message": "Are you sure?"},
+ )
+
+ def test_validate_location_with_confirm(self):
+ """Test a call on /validate on a different location with confirmation
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * Ignore the fact that the scanned location is not the expected
+ * Change the destination of the move line to the scanned one
+ * The move associated to the package level is 'done'
+
+ Note: a location to confirm is when a location is a child
+ of the destination location of the picking type used for the process
+ but not a child or the expected destination.
+ In such situation, the js application has to call /validate with
+ a ``confirmation`` flag.
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ # expected destination is 'shelf1', we'll scan shelf2 which must
+ # ask a confirmation to the user (it's still in the same picking type)
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ # acknowledge the change of destination
+ "confirmation": True,
+ },
+ )
+
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "message_type": "info",
+ "message": "The pack has been moved, you can scan a new pack.",
+ },
+ )
+
+ self.assertRecordValues(
+ package_level.move_line_ids,
+ [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}],
+ )
+ self.assertRecordValues(
+ package_level.move_line_ids.move_id,
+ [{"location_dest_id": self.shelf2.id, "state": "done"}],
+ )
+
+ def test_cancel(self):
+ """Test the happy path for single pack transfer /cancel endpoint
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * The package level has is_done to False
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+ self.assertTrue(package_level.is_done)
+
+ # keep references for later checks
+ move = package_level.move_line_ids.move_id
+ picking = move.picking_id
+
+ # now, call the service to cancel
+ response = self.service.dispatch(
+ "cancel", params={"package_level_id": package_level.id}
+ )
+ self.assertRecordValues(move, [{"state": "assigned"}])
+ self.assertRecordValues(picking, [{"state": "assigned"}])
+ self.assertRecordValues(package_level, [{"is_done": False}])
+
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "message_type": "info",
+ "message": "Canceled, you can scan a new pack.",
+ },
+ )
+
+ def test_cancel_already_canceled(self):
+ """Test a call on /cancel for already canceled package level
+
+ The pre-conditions:
+
+ * /start has been called
+ * /cancel has been called elsewhere or 'is_done' removed in odoo
+
+ Expected result:
+
+ * Nothing happens, transition with a message
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ # keep references for later checks
+ move = package_level.move_line_ids.move_id
+ move_lines = package_level.move_line_ids
+ picking = move.picking_id
+
+ # someone cancel the work started by our operator
+ move._action_cancel()
+
+ # now, call the service to cancel
+ response = self.service.dispatch(
+ "cancel", params={"package_level_id": package_level.id}
+ )
+ self.assertRecordValues(move, [{"state": "cancel"}])
+ self.assertRecordValues(picking, [{"state": "cancel"}])
+ self.assertFalse(package_level.move_line_ids)
+ self.assertFalse(move_lines.exists())
+
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "message_type": "info",
+ "message": "Canceled, you can scan a new pack.",
+ },
+ )
+
+ def test_cancel_already_done(self):
+ """Test a call on /cancel on move already done
+
+ The pre-conditions:
+
+ * /start has been called
+ * /validate has been called or move set to done in odoo
+
+ Expected result:
+
+ * No change in odoo, Transition with a message
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ # keep references for later checks
+ move = package_level.move_line_ids.move_id
+ picking = move.picking_id
+
+ # someone cancel the work started by our operator
+ move._action_done()
+
+ # now, call the service to cancel
+ response = self.service.dispatch(
+ "cancel", params={"package_level_id": package_level.id}
+ )
+ self.assertRecordValues(move, [{"state": "done"}])
+ self.assertRecordValues(picking, [{"state": "done"}])
+
+ self.assert_response(
+ response,
+ state="start",
+ message={"message_type": "info", "message": "Operation already processed."},
+ )
+
+ def test_cancel_not_found(self):
+ """Test a call on /cancel on package level not found
+
+ Expected result:
+
+ * No change in odoo, Transition with a message
+ """
+ response = self.service.dispatch("cancel", params={"package_level_id": -1})
+ self.assert_response(
+ response,
+ state="start",
+ message={
+ "message_type": "error",
+ "message": "This operation does not exist anymore.",
+ },
+ )
From 97ef84fb0204676ec5ac9757b6d273d38da3b5cf Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 7 Feb 2020 15:11:28 +0100
Subject: [PATCH 072/940] Add 'completion info' for single pack transfer
process
---
shopfloor/__manifest__.py | 8 ++-
shopfloor/demo/stock_picking_type_demo.xml | 2 +
shopfloor/services/single_pack_transfer.py | 10 ++--
shopfloor/tests/test_single_pack_transfer.py | 51 ++++++++++++++++++++
4 files changed, 67 insertions(+), 4 deletions(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 69f064b2c2..a532100ae3 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -11,7 +11,13 @@
"author": "Akretion, BCIM, Camptocamp, Odoo Community Association (OCA)",
"license": "AGPL-3",
"application": True,
- "depends": ["stock", "base_rest", "auth_api_key"],
+ "depends": [
+ "stock",
+ "base_rest",
+ "auth_api_key",
+ # https://github.com/OCA/stock-logistics-warehouse/pull/808
+ "stock_picking_completion_info",
+ ],
"data": [
"security/ir.model.access.csv",
"views/shopfloor_operation_group.xml",
diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml
index 6e473b5de6..a6a96d9d69 100644
--- a/shopfloor/demo/stock_picking_type_demo.xml
+++ b/shopfloor/demo/stock_picking_type_demo.xml
@@ -14,6 +14,7 @@
internal
+
@@ -30,6 +31,7 @@
internal
+
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 5c242a075b..1d44a60d17 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -190,9 +190,12 @@ def _response_for_location_need_confirm(self):
state="confirm_location", message=message.need_confirmation()
)
- def _response_for_validate_success(self):
+ def _response_for_validate_success(self, last=False):
message = self.actions_for("message")
- return self._response(state="start", message=message.confirm_pack_moved())
+ state = "start"
+ if last:
+ state = "show_completion_info"
+ return self._response(state=state, message=message.confirm_pack_moved())
def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
@@ -224,7 +227,8 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
return self._response_for_location_need_confirm()
pack_transfer.set_destination_and_done(move, scanned_location)
- return self._response_for_validate_success()
+ last = move.picking_id.completion_info == "next_picking_ready"
+ return self._response_for_validate_success(last=last)
def _validator_validate(self):
return {
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index df30c61845..dff4999c3d 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -28,6 +28,10 @@ def setUpClass(cls, *args, **kwargs):
cls.picking_type = cls.process.picking_type_ids
cls.picking = cls._create_initial_move()
+ # disable the completion on the picking type, we'll have specific test(s)
+ # to check the behavior of this screen
+ cls.picking_type.display_completion_info = False
+
def setUp(self):
super().setUp()
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
@@ -333,6 +337,7 @@ def test_validate(self):
The pre-conditions:
* /start has been called
+ * "completion info" is not active on the picking type
Expected result:
@@ -370,6 +375,52 @@ def test_validate(self):
[{"location_dest_id": self.shelf2.id, "state": "done"}],
)
+ def test_validate_completion_info(self):
+ """Test /validate when the picking is the last (show completion info)
+
+ When the picking is the last, we display an information screen on the
+ js application.
+
+ The pre-conditions:
+
+ * /start has been called
+ * "completion info" is active on the picking type
+ * the picking must be the last (it must not have destination moves with
+ unprocessed origin moves)
+
+ Expected result:
+
+ * The move associated to the package level is 'done'
+ * The transition goes to the completion info screen instead of starting
+ over
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ # activate the computation of this field, so we have a chance to
+ # transition to the 'show completion info' screen.
+ self.picking_type.display_completion_info = True
+
+ # now, call the service to proceed with validation of the
+ # movement
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ },
+ )
+
+ self.assert_response(
+ response,
+ state="show_completion_info",
+ message={
+ "message_type": "info",
+ "message": "The pack has been moved, you can scan a new pack.",
+ },
+ )
+
def test_validate_not_found(self):
"""Test a call on /validate on package level not found
From 9a9881180dc982e707ae3a3da230eebd92c4a88f Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 10 Feb 2020 13:34:40 +0100
Subject: [PATCH 073/940] Propose default values for HTTP vars in Swagger
---
shopfloor/models/shopfloor_process.py | 1 +
shopfloor/services/service.py | 16 +++++++++++++---
2 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py
index db4759c2d0..751eecafe7 100644
--- a/shopfloor/models/shopfloor_process.py
+++ b/shopfloor/models/shopfloor_process.py
@@ -10,6 +10,7 @@ class ShopfloorProcess(models.Model):
picking_type_ids = fields.One2many(
"stock.picking.type", "process_id", string="Operation types"
)
+ menu_ids = fields.One2many(comodel_name="shopfloor.menu", inverse_name="process_id")
def _selection_code(self):
return [
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index aa5f300e72..f31cf118a8 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -1,4 +1,4 @@
-from odoo import _
+from odoo import _, fields
from odoo.exceptions import MissingError
from odoo.osv import expression
@@ -99,6 +99,16 @@ def _get_openapi_default_parameters(self):
demo_api_key = self.env.ref(
"shopfloor.api_key_demo", raise_if_not_found=False
).key
+
+ # Try to first the first menu that implements the current service.
+ # Not all usages have a process, in that case, well set the first
+ # process found, because it should not matter for the service.
+ processes = self.env["shopfloor.process"].search([("code", "=", self._usage)])
+ menu = fields.first(processes.menu_ids)
+ if not menu:
+ menu = self.env["shopfloor.menu"].search([], limit=1)
+ profile = self.env["shopfloor.profile"].search([], limit=1)
+
defaults.extend(
[
{
@@ -117,7 +127,7 @@ def _get_openapi_default_parameters(self):
"required": True,
"schema": {"type": "integer"},
"style": "simple",
- "value": "1",
+ "value": menu.id,
},
{
"name": "SERVICE_CTX_PROFILE_ID",
@@ -126,7 +136,7 @@ def _get_openapi_default_parameters(self):
"required": True,
"schema": {"type": "integer"},
"style": "simple",
- "value": "1",
+ "value": profile.id,
},
]
)
From d1cff5cb30259dd471d1ca520248e1034e88ed65 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 11 Feb 2020 08:07:40 +0100
Subject: [PATCH 074/940] putaway: check source location is set
---
shopfloor/services/single_pack_putaway.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index b2f9294052..cec72763d9 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -96,7 +96,10 @@ def start(self, barcode):
return self._response_for_package_not_found(barcode)
assert len(pack) == 1, "We cannot have 2 packages with the same barcode"
- if not pack.location_id.is_sublocation_of(picking_type.default_location_src_id):
+ location_src = picking_type.default_location_src_id
+ assert location_src, "Picking type has no default source location"
+
+ if not pack.location_id.is_sublocation_of(location_src):
return self._response_for_forbidden_package(barcode, picking_type)
existing_operation = self.env["stock.move.line"].search(
From b34b60ef884dc4eb4c473e1b6481d0b5be5c044c Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 11 Feb 2020 08:08:11 +0100
Subject: [PATCH 075/940] putaway: fix call to old method name
---
shopfloor/services/single_pack_putaway.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index cec72763d9..8b2e579950 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -123,7 +123,7 @@ def start(self, barcode):
existing_operation
and existing_operation[0].picking_id.picking_type_id != picking_type
):
- return self._response_for_forbidden_start(existing_operation)
+ return self._response_for_forbidden_scan_pack(existing_operation)
elif existing_operation:
return self._response_for_start_to_confirm(existing_operation, pack)
From 29eec2b4620fa0981807b09d18d40005e65b3ed2 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 11 Feb 2020 08:09:47 +0100
Subject: [PATCH 076/940] backend: improve menu data
---
shopfloor/services/menu.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 96847ce7c4..77670ff2e7 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -67,8 +67,9 @@ def _record_return_schema(self):
return {
"id": {"coerce": to_int, "required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
- "process": {"type": "string", "nullable": False, "required": True},
+ "process_code": {"type": "string", "nullable": False, "required": True},
+ "process_id": {"coerce": to_int, "required": True, "type": "integer"},
}
def _convert_one_record(self, record):
- return {"id": record.id, "name": record.name, "process": record.process_code}
+ return {"id": record.id, "name": record.name, "process_code": record.process_code, "process_id": record.process_id.id}
From 0960d80ef9dc661342ab4e9d72d4605c1c960450 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 11 Feb 2020 11:48:28 +0100
Subject: [PATCH 077/940] backend: special headers are needed for process only
---
shopfloor/controllers/main.py | 22 ++++++++++++++++++----
1 file changed, 18 insertions(+), 4 deletions(-)
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index 297b0e4033..e2a7dd79b2 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -9,6 +9,7 @@ class ShopfloorController(main.RestController):
_root_path = "/shopfloor/"
_collection_name = "shopfloor.service"
_default_auth = "api_key"
+ _non_process_services = ('app', 'menu', 'profile')
def _get_component_context(self):
"""
@@ -19,16 +20,29 @@ def _get_component_context(self):
"""
res = super(ShopfloorController, self)._get_component_context()
headers = request.httprequest.environ
+
+ res['menu'] = None
+ res['profile'] = None
+ if self._is_process_enpoint(request.httprequest.path):
+ res.update(self._get_process_context(headers, request.env))
+ return res
+
+ def _is_process_enpoint(self, request_path):
+ # '/shopfloor/app/user_config' -> app/config
+ service_path = request_path.split(self._root_path)[-1]
+ return not service_path.startswith(self._non_process_services)
+
+ def _get_process_context(self, headers, env):
+ ctx = {}
try:
menu_id = int(headers.get("HTTP_SERVICE_CTX_MENU_ID"))
except (TypeError, ValueError):
raise BadRequest("HTTP_SERVICE_CTX_MENU_ID must be set with an integer")
- res["menu"] = request.env["shopfloor.menu"].browse(menu_id)
+ ctx["menu"] = env["shopfloor.menu"].browse(menu_id)
try:
profile_id = int(headers.get("HTTP_SERVICE_CTX_PROFILE_ID"))
except (TypeError, ValueError):
raise BadRequest("HTTP_SERVICE_CTX_PROFILE_ID must be set with an integer")
- res["profile"] = request.env["shopfloor.profile"].browse(profile_id)
-
- return res
+ ctx["profile"] = env["shopfloor.profile"].browse(profile_id)
+ return ctx
From f448b9d9029955c668ab855d302c7cfedfdda507 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 11 Feb 2020 11:57:32 +0100
Subject: [PATCH 078/940] backend: putaway rollback to "start" endpoint
---
shopfloor/services/single_pack_putaway.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 8b2e579950..cec72763d9 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -123,7 +123,7 @@ def start(self, barcode):
existing_operation
and existing_operation[0].picking_id.picking_type_id != picking_type
):
- return self._response_for_forbidden_scan_pack(existing_operation)
+ return self._response_for_forbidden_start(existing_operation)
elif existing_operation:
return self._response_for_start_to_confirm(existing_operation, pack)
From 6a4e10748eb68d3cb43eaad000638dadaedd78c7 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 11 Feb 2020 15:52:15 +0100
Subject: [PATCH 079/940] Add skeleton for cluster picking services
---
shopfloor/demo/shopfloor_menu_demo.xml | 6 ++++++
shopfloor/demo/shopfloor_process_demo.xml | 5 +++++
shopfloor/demo/stock_picking_type_demo.xml | 17 +++++++++++++++++
shopfloor/models/shopfloor_process.py | 3 ++-
shopfloor/services/__init__.py | 1 +
shopfloor/services/cluster_picking.py | 10 ++++++++++
shopfloor/tests/__init__.py | 2 ++
shopfloor/tests/test_cluster_picking.py | 17 +++++++++++++++++
8 files changed, 60 insertions(+), 1 deletion(-)
create mode 100644 shopfloor/services/cluster_picking.py
create mode 100644 shopfloor/tests/test_cluster_picking.py
diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml
index 6d0d511f79..f19f603dfb 100644
--- a/shopfloor/demo/shopfloor_menu_demo.xml
+++ b/shopfloor/demo/shopfloor_menu_demo.xml
@@ -12,4 +12,10 @@
+
+
diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml
index 3f869cb457..f2aab9b212 100644
--- a/shopfloor/demo/shopfloor_process_demo.xml
+++ b/shopfloor/demo/shopfloor_process_demo.xml
@@ -10,4 +10,9 @@
single_pack_transfer
+
+ Cluster Picking
+ cluster_picking
+
+
diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml
index a6a96d9d69..80792ca479 100644
--- a/shopfloor/demo/stock_picking_type_demo.xml
+++ b/shopfloor/demo/stock_picking_type_demo.xml
@@ -34,4 +34,21 @@
+
+ Cluster Picking
+ CPI
+
+
+
+
+
+
+
+
+ internal
+
+
+
+
+
diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py
index 751eecafe7..24890d6a59 100644
--- a/shopfloor/models/shopfloor_process.py
+++ b/shopfloor/models/shopfloor_process.py
@@ -14,7 +14,8 @@ class ShopfloorProcess(models.Model):
def _selection_code(self):
return [
- # these must match a REST service
+ # these must match a REST service's '_usage'
("single_pack_putaway", "Single Pack Put-away"),
("single_pack_transfer", "Single Pack Transfer"),
+ ("cluster_picking", "Cluster Picking"),
]
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 5eb564a6a6..73b5657920 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -4,5 +4,6 @@
from . import menu
from . import pack
from . import location
+from . import cluster_picking
from . import single_pack_putaway
from . import single_pack_transfer
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
new file mode 100644
index 0000000000..8493e6299d
--- /dev/null
+++ b/shopfloor/services/cluster_picking.py
@@ -0,0 +1,10 @@
+from odoo.addons.component.core import Component
+
+
+class ClusterPicking(Component):
+ """Methods for the Cluster Picking Process"""
+
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.cluster.picking"
+ _usage = "cluster_picking"
+ _description = __doc__
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 188a3788ab..b9fe5004f9 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -1 +1,3 @@
from . import test_single_pack_putaway
+from . import test_single_pack_transfer
+from . import test_cluster_picking
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
new file mode 100644
index 0000000000..dd53151e59
--- /dev/null
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -0,0 +1,17 @@
+from .common import CommonCase
+
+
+class ClusterPickingCase(CommonCase):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
+ cls.process = cls.menu.process_id
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.picking_type = cls.process.picking_type_ids
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
+ self.service = work.component(usage="cluster_picking")
From 7518be5c17377e11ddd1e994b8991620e3a4fe64 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 11 Feb 2020 16:18:32 +0100
Subject: [PATCH 080/940] backend: use sub-dict for returning menu's process
---
shopfloor/services/menu.py | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 77670ff2e7..e62fa87ae2 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -67,9 +67,19 @@ def _record_return_schema(self):
return {
"id": {"coerce": to_int, "required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
- "process_code": {"type": "string", "nullable": False, "required": True},
- "process_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "process": {
+ "type": "dict",
+ "required": True,
+ "schema": {
+ "code": {"type": "string", "nullable": False, "required": True},
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ },
+ },
}
def _convert_one_record(self, record):
- return {"id": record.id, "name": record.name, "process_code": record.process_code, "process_id": record.process_id.id}
+ return {
+ "id": record.id,
+ "name": record.name,
+ "process": {"id": record.process_id.id, "code": record.process_code},
+ }
From 508e300cc5238ffc7beb1f761ce00f98ca912dfd Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 12 Feb 2020 12:29:36 +0100
Subject: [PATCH 081/940] Add picking batch service
---
shopfloor/__manifest__.py | 1 +
shopfloor/controllers/main.py | 6 +-
shopfloor/services/__init__.py | 1 +
shopfloor/services/picking_batch.py | 94 +++++++++++++++++++++++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/common.py | 13 ++++
shopfloor/tests/test_picking_batch.py | 105 ++++++++++++++++++++++++++
7 files changed, 218 insertions(+), 3 deletions(-)
create mode 100644 shopfloor/services/picking_batch.py
create mode 100644 shopfloor/tests/test_picking_batch.py
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index a532100ae3..d2dbcaa11f 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -13,6 +13,7 @@
"application": True,
"depends": [
"stock",
+ "stock_picking_batch",
"base_rest",
"auth_api_key",
# https://github.com/OCA/stock-logistics-warehouse/pull/808
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index e2a7dd79b2..87ce6ef967 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -9,7 +9,7 @@ class ShopfloorController(main.RestController):
_root_path = "/shopfloor/"
_collection_name = "shopfloor.service"
_default_auth = "api_key"
- _non_process_services = ('app', 'menu', 'profile')
+ _non_process_services = ("app", "menu", "profile")
def _get_component_context(self):
"""
@@ -21,8 +21,8 @@ def _get_component_context(self):
res = super(ShopfloorController, self)._get_component_context()
headers = request.httprequest.environ
- res['menu'] = None
- res['profile'] = None
+ res["menu"] = None
+ res["profile"] = None
if self._is_process_enpoint(request.httprequest.path):
res.update(self._get_process_context(headers, request.env))
return res
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 73b5657920..f3b34ed433 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -7,3 +7,4 @@
from . import cluster_picking
from . import single_pack_putaway
from . import single_pack_transfer
+from . import picking_batch
diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py
new file mode 100644
index 0000000000..df580ba85f
--- /dev/null
+++ b/shopfloor/services/picking_batch.py
@@ -0,0 +1,94 @@
+from odoo.osv import expression
+
+from odoo.addons.base_rest.components.service import to_int
+from odoo.addons.component.core import Component
+
+
+class PickingBatch(Component):
+ """Picking Batch services for the client application."""
+
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.picking.batch"
+ _usage = "picking_batch"
+ _expose_model = "stock.picking.batch"
+ _description = __doc__
+
+ def _get_base_search_domain(self):
+ base_domain = super()._get_base_search_domain()
+ user = self.env.user
+ return expression.AND(
+ [
+ base_domain,
+ [
+ "|",
+ "&",
+ ("user_id", "=", False),
+ ("state", "=", "draft"),
+ "&",
+ ("user_id", "=", user.id),
+ ("state", "in", ("draft", "in_progress")),
+ ],
+ ]
+ )
+
+ def _search(self, name_fragment=None):
+ domain = self._get_base_search_domain()
+ if name_fragment:
+ domain.append(("name", "ilike", name_fragment))
+ records = self.env[self._expose_model].search(domain, order="id asc")
+ records = records.filtered(
+ # Include done/cancel because we want to be able to work on the
+ # batch even if some pickings are done/canceled. They'll should be
+ # ignored later.
+ lambda batch: all(
+ picking.state in ("assigned", "done", "cancel")
+ for picking in batch.picking_ids
+ )
+ )
+ # TODO why already convert to json in the internal method?
+ return self._to_json(records)
+
+ def search(self, name_fragment=None):
+ """List available stock picking batches for current user
+
+ Show only picking batches where all the pickings are available.
+ """
+ json_records = self._search(name_fragment=name_fragment)
+ return self._response(data={"size": len(json_records), "records": json_records})
+
+ def _validator_search(self):
+ return {
+ "name_fragment": {"type": "string", "nullable": True, "required": False}
+ }
+
+ def _validator_return_search(self):
+ return self._response_schema(
+ {
+ "size": {"coerce": to_int, "required": True, "type": "integer"},
+ "records": {
+ "type": "list",
+ "required": True,
+ "schema": {"type": "dict", "schema": self._record_return_schema},
+ },
+ }
+ )
+
+ @property
+ def _record_return_schema(self):
+ return {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "picking_count": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_count": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def _convert_one_record(self, record):
+ assigned_pickings = record.picking_ids.filtered(
+ lambda picking: picking.state == "assigned"
+ )
+ return {
+ "id": record.id,
+ "name": record.name,
+ "picking_count": len(assigned_pickings),
+ "move_line_count": len(assigned_pickings.mapped("move_line_ids")),
+ }
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index b9fe5004f9..b9d6b8a20e 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -1,3 +1,4 @@
+from . import test_picking_batch
from . import test_single_pack_putaway
from . import test_single_pack_transfer
from . import test_cluster_picking
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 8b58279f0d..52b4d05aa5 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -57,6 +57,7 @@ def setUpClassVars(cls):
cls.stock_location = stock_location
cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone")
cls.dispatch_location.barcode = "DISPATCH"
+ cls.packing_location = cls.env.ref("stock.location_pack_zone")
cls.input_location = cls.env.ref("stock.stock_location_company")
cls.shelf1 = cls.env.ref("stock.stock_location_components")
cls.shelf2 = cls.env.ref("stock.stock_location_14")
@@ -123,3 +124,15 @@ def assert_dict(self, current, expected):
node_expected,
"\n\nNode's specs:\n%s" % (pformat(node_original_expected)),
)
+
+ def _update_qty_in_location(self, location, product, quantity):
+ self.env["stock.quant"]._update_available_quantity(product, location, quantity)
+
+ def _fill_stock_for_pickings(self, pickings):
+ product_locations = {}
+ for move in self.all_batches.mapped("picking_ids.move_lines"):
+ key = (move.product_id, move.location_id)
+ product_locations.setdefault(key, 0)
+ product_locations[key] += move.product_qty
+ for (product, location), qty in product_locations.items():
+ self._update_qty_in_location(location, product, qty)
diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py
new file mode 100644
index 0000000000..62e4f70a38
--- /dev/null
+++ b/shopfloor/tests/test_picking_batch.py
@@ -0,0 +1,105 @@
+from odoo.tests.common import Form
+
+from .common import CommonCase
+
+
+class BatchPickingCase(CommonCase):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.product_a = cls.env["product.product"].create(
+ {"name": "Product A", "type": "product"}
+ )
+ cls.product_b = cls.env["product.product"].create(
+ {"name": "Product B", "type": "product"}
+ )
+ # which menu we pick should not matter for the batch picking api
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
+ cls.process = cls.menu.process_id
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.picking_type = cls.process.picking_type_ids
+ cls.batch1 = cls._create_picking_batch(cls.product_a)
+ cls.batch2 = cls._create_picking_batch(cls.product_a)
+ cls.batch3 = cls._create_picking_batch(cls.product_a)
+ cls.batch4 = cls._create_picking_batch(cls.product_b)
+ cls.batch5 = cls._create_picking_batch(cls.product_b)
+ cls.batch6 = cls._create_picking_batch(cls.product_b)
+ cls.all_batches = (
+ cls.batch1 + cls.batch2 + cls.batch3 + cls.batch4 + cls.batch5 + cls.batch6
+ )
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
+ self.service = work.component(usage="picking_batch")
+
+ @classmethod
+ def _create_picking_batch(cls, product):
+ picking_form = Form(cls.env["stock.picking"])
+ picking_form.picking_type_id = cls.picking_type
+ picking_form.location_id = cls.stock_location
+ picking_form.location_dest_id = cls.packing_location
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = product
+ move.product_uom_qty = 1
+ picking = picking_form.save()
+ picking.action_confirm()
+ picking.action_assign()
+
+ batch_form = Form(cls.env["stock.picking.batch"])
+ batch_form.picking_ids.add(picking)
+ return batch_form.save()
+
+ def test_search_empty(self):
+ """No batch is available"""
+ # Simulate the client asking the list of picking batch
+ response = self.service.dispatch("search")
+ # none of the pickings are assigned, so we can't work on them
+ self.assert_response(response, data={"size": 0, "records": []})
+
+ def test_search(self):
+ """Return only draft batches with assigned pickings """
+ pickings = self.all_batches.mapped("picking_ids")
+ self._fill_stock_for_pickings(pickings)
+ pickings.action_assign()
+ self.assertTrue(all(p.state == "assigned" for p in pickings))
+ # we should not have done batches in list
+ self.batch5.state = "done"
+ # nor canceled batches
+ self.batch6.state = "cancel"
+ # we should not have batches in progress
+ self.batch4.user_id = self.env.ref("base.user_demo")
+ self.batch4.confirm_picking()
+ # unless it's assigned to our user
+ self.batch3.user_id = self.env.user
+ self.batch3.confirm_picking()
+
+ # Simulate the client asking the list of picking batch
+ response = self.service.dispatch("search")
+ self.assert_response(
+ response,
+ data={
+ "size": 3,
+ "records": [
+ {
+ "id": self.batch1.id,
+ "name": self.batch1.name,
+ "picking_count": 1,
+ "move_line_count": 1,
+ },
+ {
+ "id": self.batch2.id,
+ "name": self.batch2.name,
+ "picking_count": 1,
+ "move_line_count": 1,
+ },
+ {
+ "id": self.batch3.id,
+ "name": self.batch3.name,
+ "picking_count": 1,
+ "move_line_count": 1,
+ },
+ ],
+ },
+ )
From 17021ddd3ff360a8b11e4cc1f59ea94c957c3d8d Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 13 Feb 2020 11:42:18 +0100
Subject: [PATCH 082/940] Use components for validators
The validator methods and schema in the service components make
it quite had to read. Dedicated components for validators separate
methods by concern and allow to remove the prefixes on methods.
Needs: https://github.com/OCA/rest-framework/pull/58
---
shopfloor/services/__init__.py | 1 +
shopfloor/services/app.py | 20 +++-
shopfloor/services/location.py | 36 +++++--
shopfloor/services/menu.py | 54 ++++++----
shopfloor/services/pack.py | 30 ++++--
shopfloor/services/picking_batch.py | 46 +++++---
shopfloor/services/profile.py | 44 +++++---
shopfloor/services/service.py | 40 +++----
shopfloor/services/single_pack_putaway.py | 34 ++++--
shopfloor/services/single_pack_transfer.py | 118 ++++++++++++---------
shopfloor/services/validator.py | 45 ++++++++
11 files changed, 315 insertions(+), 153 deletions(-)
create mode 100644 shopfloor/services/validator.py
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index f3b34ed433..7a137fed0d 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -1,4 +1,5 @@
from . import service
+from . import validator
from . import app
from . import profile
from . import menu
diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py
index ed1bbf4880..aa70424129 100644
--- a/shopfloor/services/app.py
+++ b/shopfloor/services/app.py
@@ -14,10 +14,26 @@ def user_config(self):
profiles = self.component("profile")._search()
return self._response(data={"menus": menus, "profiles": profiles})
- def _validator_user_config(self):
+
+class ShopfloorAppValidator(Component):
+ """Validators for the Application endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.app.validator"
+ _usage = "app.validator"
+
+ def user_config(self):
return {}
- def _validator_return_user_config(self):
+
+class ShopfloorAppValidatorResponse(Component):
+ """Validators for the Application endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.app.validator.response"
+ _usage = "app.validator.response"
+
+ def user_config(self):
menu_service = self.component("menu")
profile_service = self.component("profile")
return self._response_schema(
diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py
index a789869120..7bd237f4bd 100644
--- a/shopfloor/services/location.py
+++ b/shopfloor/services/location.py
@@ -36,12 +36,36 @@ def _get_base_search_domain(self):
# TODO add filter on warehouse of the current profile
return super()._get_base_search_domain()
- def _validator_search(self):
+ def _convert_one_record(self, record):
+ return {
+ "id": record.id,
+ "name": record.name,
+ "complete_name": record.complete_name,
+ "barcode": record.barcode or "",
+ }
+
+
+class ShopfloorLocationValidator(Component):
+ """Validators for the Location endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.location.validator"
+ _usage = "location.validator"
+
+ def search(self):
return {
"name_fragment": {"type": "string", "nullable": True, "required": False}
}
- def _validator_return_search(self):
+
+class ShopfloorLocationValidatorResponse(Component):
+ """Validators for the Location endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.location.validator.response"
+ _usage = "location.validator.response"
+
+ def search(self):
return self._response_schema(
{
"size": {"coerce": to_int, "required": True, "type": "integer"},
@@ -75,11 +99,3 @@ def _validator_return_search(self):
},
}
)
-
- def _convert_one_record(self, record):
- return {
- "id": record.id,
- "name": record.name,
- "complete_name": record.complete_name,
- "barcode": record.barcode or "",
- }
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index e62fa87ae2..fd2a015b86 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -45,25 +45,36 @@ def search(self, name_fragment=None):
json_records = self._search(name_fragment=name_fragment)
return self._response(data={"size": len(json_records), "records": json_records})
- def _validator_search(self):
+ def _convert_one_record(self, record):
+ return {
+ "id": record.id,
+ "name": record.name,
+ "process": {"id": record.process_id.id, "code": record.process_code},
+ }
+
+
+class ShopfloorMenuValidator(Component):
+ """Validators for the Menu endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.menu.validator"
+ _usage = "menu.validator"
+
+ def search(self):
return {
"name_fragment": {"type": "string", "nullable": True, "required": False}
}
- def _validator_return_search(self):
- return self._response_schema(
- {
- "size": {"coerce": to_int, "required": True, "type": "integer"},
- "records": {
- "type": "list",
- "required": True,
- "schema": {"type": "dict", "schema": self._record_return_schema},
- },
- }
- )
+
+class ShopfloorMenuValidatorResponse(Component):
+ """Validators for the Menu endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.menu.validator.response"
+ _usage = "menu.validator.response"
@property
- def _record_return_schema(self):
+ def _record_schema(self):
return {
"id": {"coerce": to_int, "required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
@@ -77,9 +88,14 @@ def _record_return_schema(self):
},
}
- def _convert_one_record(self, record):
- return {
- "id": record.id,
- "name": record.name,
- "process": {"id": record.process_id.id, "code": record.process_code},
- }
+ def return_search(self):
+ return self._response_schema(
+ {
+ "size": {"coerce": to_int, "required": True, "type": "integer"},
+ "records": {
+ "type": "list",
+ "required": True,
+ "schema": {"type": "dict", "schema": self._record_schema},
+ },
+ }
+ )
diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py
index 06117ef483..bf4d3ea2f3 100644
--- a/shopfloor/services/pack.py
+++ b/shopfloor/services/pack.py
@@ -16,12 +16,6 @@ def get_by_name(self, pack_name):
package = search.package_from_scan(pack_name)
return self._response(data=self._to_json(package)[:1])
- def _validator_get_by_name(self):
- return {"pack_name": {"type": "string", "nullable": False, "required": True}}
-
- def _validator_return_get_by_name(self):
- return self._response_schema(self._record_return_schema)
-
def _convert_one_record(self, record):
return {
"id": record.id,
@@ -29,8 +23,27 @@ def _convert_one_record(self, record):
"location": {"id": record.location_id.id, "name": record.location_id.name},
}
+
+class ShopfloorPackValidator(Component):
+ """Validators for the Pack endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.pack.validator"
+ _usage = "pack.validator"
+
+ def get_by_name(self):
+ return {"pack_name": {"type": "string", "nullable": False, "required": True}}
+
+
+class ShopfloorPackValidatorResponse(Component):
+ """Validators for the Pack endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.pack.validator.response"
+ _usage = "pack.validator.response"
+
@property
- def _record_return_schema(self):
+ def _record_schema(self):
return {
"id": {"coerce": to_int, "required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
@@ -42,3 +55,6 @@ def _record_return_schema(self):
},
},
}
+
+ def get_by_name(self):
+ return self._response_schema(self._record_schema)
diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py
index df580ba85f..a4d2cfe2d8 100644
--- a/shopfloor/services/picking_batch.py
+++ b/shopfloor/services/picking_batch.py
@@ -56,39 +56,55 @@ def search(self, name_fragment=None):
json_records = self._search(name_fragment=name_fragment)
return self._response(data={"size": len(json_records), "records": json_records})
- def _validator_search(self):
+ def _convert_one_record(self, record):
+ assigned_pickings = record.picking_ids.filtered(
+ lambda picking: picking.state == "assigned"
+ )
+ return {
+ "id": record.id,
+ "name": record.name,
+ "picking_count": len(assigned_pickings),
+ "move_line_count": len(assigned_pickings.mapped("move_line_ids")),
+ }
+
+
+class ShopfloorPickingBatchValidator(Component):
+ """Validators for the Picking_Batch endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.picking.batch.validator"
+ _usage = "picking_batch.validator"
+
+ def search(self):
return {
"name_fragment": {"type": "string", "nullable": True, "required": False}
}
- def _validator_return_search(self):
+
+class ShopfloorPickingBatchValidatorResponse(Component):
+ """Validators for the Picking_Batch endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.picking.batch.validator.response"
+ _usage = "picking_batch.validator.response"
+
+ def search(self):
return self._response_schema(
{
"size": {"coerce": to_int, "required": True, "type": "integer"},
"records": {
"type": "list",
"required": True,
- "schema": {"type": "dict", "schema": self._record_return_schema},
+ "schema": {"type": "dict", "schema": self._record_schema},
},
}
)
@property
- def _record_return_schema(self):
+ def _record_schema(self):
return {
"id": {"coerce": to_int, "required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"picking_count": {"coerce": to_int, "required": True, "type": "integer"},
"move_line_count": {"coerce": to_int, "required": True, "type": "integer"},
}
-
- def _convert_one_record(self, record):
- assigned_pickings = record.picking_ids.filtered(
- lambda picking: picking.state == "assigned"
- )
- return {
- "id": record.id,
- "name": record.name,
- "picking_count": len(assigned_pickings),
- "move_line_count": len(assigned_pickings.mapped("move_line_ids")),
- }
diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py
index 9f59ef0706..395971a73a 100644
--- a/shopfloor/services/profile.py
+++ b/shopfloor/services/profile.py
@@ -58,24 +58,50 @@ def _get_base_search_domain(self):
]
)
- def _validator_search(self):
+ def _convert_one_record(self, record):
+ return {
+ "id": record.id,
+ "name": record.name,
+ "warehouse": {
+ "id": record.warehouse_id.id,
+ "name": record.warehouse_id.name,
+ },
+ }
+
+
+class ShopfloorProfileValidator(Component):
+ """Validators for the Profile endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.profile.validator"
+ _usage = "profile.validator"
+
+ def search(self):
return {
"name_fragment": {"type": "string", "nullable": True, "required": False}
}
- def _validator_return_search(self):
+
+class ShopfloorProfileValidatorResponse(Component):
+ """Validators for the Profile endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.profile.validator.response"
+ _usage = "profile.validator.response"
+
+ def search(self):
return self._response_schema(
{
"size": {"coerce": to_int, "required": True, "type": "integer"},
"records": {
"type": "list",
- "schema": {"type": "dict", "schema": self._record_return_schema},
+ "schema": {"type": "dict", "schema": self._record_schema},
},
}
)
@property
- def _record_return_schema(self):
+ def _record_schema(self):
return {
"id": {"coerce": to_int, "required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
@@ -87,13 +113,3 @@ def _record_return_schema(self):
},
},
}
-
- def _convert_one_record(self, record):
- return {
- "id": record.id,
- "name": record.name,
- "warehouse": {
- "id": record.warehouse_id.id,
- "name": record.warehouse_id.name,
- },
- }
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index f31cf118a8..44a3f336fa 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -48,6 +48,20 @@ def _to_json(self, records):
res.append(self._convert_one_record(record))
return res
+ def _get_input_validator(self, method_name):
+ # override the method to get the validator in a component
+ # instead of a method, to keep things apart
+ validator_component = self.component(usage="%s.validator" % self._usage)
+ return validator_component._get_validator(method_name)
+
+ def _get_output_validator(self, method_name):
+ # override the method to get the validator in a component
+ # instead of a method, to keep things apart
+ validator_component = self.component(
+ usage="%s.validator.response" % self._usage
+ )
+ return validator_component._get_validator(method_name)
+
def _response(self, data=None, state=None, message=None):
"""Base "envelope" for the responses
@@ -68,32 +82,6 @@ def _response(self, data=None, state=None, message=None):
response["message"] = message
return response
- def _response_schema(self, data_schema=None):
- """Schema for the return validator
-
- Must be used for the schema of all responses.
- The "data" part can be customized and is optional,
- it must be a dictionary.
- """
- if not data_schema:
- data_schema = {}
- return {
- "data": {"type": "dict", "required": False, "schema": data_schema},
- "state": {"type": "string", "required": False},
- "message": {
- "type": "dict",
- "required": False,
- "schema": {
- "message_type": {
- "type": "string",
- "required": True,
- "allowed": ["info", "warning", "error"],
- },
- "message": {"type": "string", "required": True},
- },
- },
- }
-
def _get_openapi_default_parameters(self):
defaults = super()._get_openapi_default_parameters()
demo_api_key = self.env.ref(
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index cec72763d9..ce74e746a6 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -248,28 +248,44 @@ def cancel(self, package_level_id):
package.move_line_ids.move_id._action_cancel()
return self._response_for_confirm_move_cancellation()
- def _validator_cancel(self):
+
+class SinglePackPutawayValidator(Component):
+ """Validators for Single Pack Putaway methods"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.single.pack.putaway.validator"
+ _usage = "single_pack_putaway.validator"
+
+ def start(self):
+ return {"barcode": {"type": "string", "nullable": False, "required": True}}
+
+ def cancel(self):
return {
"package_level_id": {"coerce": to_int, "required": True, "type": "integer"}
}
- def _validator_return_cancel(self):
- return self._response_schema()
-
- def _validator_validate(self):
+ def validate(self):
return {
"package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
"location_barcode": {"type": "string", "nullable": False, "required": True},
"confirmation": {"type": "boolean", "nullable": True, "required": False},
}
- def _validator_return_validate(self):
+
+class SinglePackPutawayValidatorResponse(Component):
+ """Validators for Single Pack Putaway methods responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.single.pack.putaway.validator.response"
+ _usage = "single_pack_putaway.validator.response"
+
+ def cancel(self):
return self._response_schema()
- def _validator_start(self):
- return {"barcode": {"type": "string", "nullable": False, "required": True}}
+ def validate(self):
+ return self._response_schema()
- def _validator_return_start(self):
+ def start(self):
return self._response_schema(
{
"id": {"coerce": to_int, "required": True, "type": "integer"},
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 1d44a60d17..832321b17c 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -123,45 +123,6 @@ def start(self, barcode):
existing_operations[0].package_level_id.is_done = True
return self._response_for_start_success(existing_operations[0], pack)
- def _validator_start(self):
- return {"barcode": {"type": "string", "nullable": False, "required": True}}
-
- def _validator_return_start(self):
- return self._response_schema(
- {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "location_src": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "location_dst": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "product": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "picking": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- }
- )
-
def _response_for_package_level_not_found(self):
message = self.actions_for("message")
return self._response(state="start", message=message.operation_not_found())
@@ -230,16 +191,6 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
last = move.picking_id.completion_info == "next_picking_ready"
return self._response_for_validate_success(last=last)
- def _validator_validate(self):
- return {
- "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
- "location_barcode": {"type": "string", "nullable": False, "required": True},
- "confirmation": {"type": "boolean", "required": False},
- }
-
- def _validator_return_validate(self):
- return self._response_schema()
-
def cancel(self, package_level_id):
package = self.env["stock.package_level"].browse(package_level_id)
if not package.exists():
@@ -262,10 +213,75 @@ def _response_for_confirm_cancel(self):
state="start", message=message.confirm_canceled_scan_next_pack()
)
- def _validator_cancel(self):
+
+class SinglePackTransferValidator(Component):
+ """Validators for Single Pack Transfer methods"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.single.pack.transfer.validator"
+ _usage = "single_pack_transfer.validator"
+
+ def start(self):
+ return {"barcode": {"type": "string", "nullable": False, "required": True}}
+
+ def cancel(self):
return {
"package_level_id": {"coerce": to_int, "required": True, "type": "integer"}
}
- def _validator_return_cancel(self):
+ def validate(self):
+ return {
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "location_barcode": {"type": "string", "nullable": False, "required": True},
+ "confirmation": {"type": "boolean", "required": False},
+ }
+
+
+class SinglePackTransferValidatorResponse(Component):
+ """Validators for Single Pack Transfer methods responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.single.pack.transfer.validator.response"
+ _usage = "single_pack_transfer.validator.response"
+
+ def start(self):
+ return self._response_schema(
+ {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "location_src": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "location_dst": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "product": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ }
+ )
+
+ def cancel(self):
+ return self._response_schema()
+
+ def validate(self):
return self._response_schema()
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
new file mode 100644
index 0000000000..0ecee638b7
--- /dev/null
+++ b/shopfloor/services/validator.py
@@ -0,0 +1,45 @@
+from odoo.addons.component.core import AbstractComponent
+
+
+class BaseShopfloorValidator(AbstractComponent):
+ """Base class for Validators"""
+
+ _inherit = "base.rest.service"
+ _name = "base.shopfloor.validator"
+ _collection = "shopfloor.service"
+ _is_rest_service_component = False
+
+
+class BaseShopfloorValidatorResponse(AbstractComponent):
+ """Base class for Validator for Responses"""
+
+ _inherit = "base.rest.service"
+ _name = "base.shopfloor.validator.response"
+ _collection = "shopfloor.service"
+ _is_rest_service_component = False
+
+ def _response_schema(self, data_schema=None):
+ """Schema for the return validator
+
+ Must be used for the schema of all responses.
+ The "data" part can be customized and is optional,
+ it must be a dictionary.
+ """
+ if not data_schema:
+ data_schema = {}
+ return {
+ "data": {"type": "dict", "required": False, "schema": data_schema},
+ "state": {"type": "string", "required": False},
+ "message": {
+ "type": "dict",
+ "required": False,
+ "schema": {
+ "message_type": {
+ "type": "string",
+ "required": True,
+ "allowed": ["info", "warning", "error"],
+ },
+ "message": {"type": "string", "required": True},
+ },
+ },
+ }
From 0b9c2c1b84c0ac49e5dfe1cf4aacfe9c48061cd4 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 13 Feb 2020 11:48:05 +0100
Subject: [PATCH 083/940] Add schemas / validators for cluster picking methods
---
shopfloor/services/cluster_picking.py | 556 ++++++++++++++++++++++++
shopfloor/tests/test_cluster_picking.py | 33 ++
2 files changed, 589 insertions(+)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 8493e6299d..42211235fa 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1,3 +1,4 @@
+from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
@@ -8,3 +9,558 @@ class ClusterPicking(Component):
_name = "shopfloor.cluster.picking"
_usage = "cluster_picking"
_description = __doc__
+
+ def find_batch(self):
+ """Find a picking batch to work on and start it
+
+ Usually the starting point of the process.
+ """
+ return self._response()
+
+ def select(self, batch_id):
+ """Manually select a picking batch
+
+ The client application can use the service /picking_batch/search
+ to get the list of candidate batches.
+ """
+ return self._response()
+
+ def unassign(self, batch_id):
+ """Unassign and reset to draft a started picking batch"""
+ return self._response(state="start")
+
+ def scan_line(self, move_line_id, barcode):
+ """Scan a location, a pack, a product or a lots
+
+ To validate the operator takes the expected pack or product.
+ """
+ return self._response()
+
+ def scan_destination_pack(self, move_line_id, barcode):
+ """Scan the destination pack (bin) for a move line
+
+ It will change the destination of the move line
+ """
+ return self._response()
+
+ def prepare_unload(self, picking_batch_id):
+ """Scan the destination pack (bin) for a move line
+
+ It will change the destination of the move line
+ """
+ return self._response()
+
+ def is_zero(self, move_line_id, zero):
+ """Confirm or not if the source location of a move has zero qty"""
+ return self._response()
+
+ def skip_line(self, move_line_id):
+ """Skip a line. The line will be processed at the end."""
+ return self._response()
+
+ def stock_issue(self, move_line_id):
+ """Declare a stock issue for a line"""
+ return self._response()
+
+ def change_pack_lot(self, move_line_id, barcode):
+ """Change the expected pack or the lot for a line"""
+ return self._response()
+
+ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
+ """Set the destination for all the lines of the batch"""
+ return self._response()
+
+ def unload_split(self, picking_batch_id, barcode, confirmation=False):
+ """Set the destination for all the lines of the batch"""
+ return self._response()
+
+ def unload_scan_pack(self, move_line_id, barcode):
+ """Check that the operator scans the correct pack (bin)"""
+ return self._response()
+
+ def unload_scan_destination(self, move_line_id, barcode, confirmation=False):
+ """Check that the operator scans the correct pack (bin)"""
+ return self._response()
+
+
+class ShopfloorClusterPickingValidator(Component):
+ """Validators for the Cluster Picking endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.cluster_picking.validator"
+ _usage = "cluster_picking.validator"
+
+ def find_batch(self):
+ return {}
+
+ def select(self):
+ return {"batch_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def unassign(self):
+ return {"batch_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def scan_line(self):
+ return {
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def scan_destination_pack(self):
+ return {
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def prepare_unload(self):
+ return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
+ }
+
+ def is_zero(self):
+ return {
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "zero": {"coerce": to_bool, "required": True, "type": "boolean"},
+ }
+
+ def skip_line(self):
+ return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def stock_issue(self):
+ return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def change_pack_lot(self):
+ return {
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def set_destination_all(self):
+ return {
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
+ }
+
+ def unload_split(self):
+ return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
+ }
+
+ def unload_scan_pack(self):
+ return {
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def unload_scan_destination(self):
+ return {
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
+ }
+
+
+class ShopfloorClusterPickingValidatorResponse(Component):
+ """Validators for the Cluster Picking endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.cluster_picking.validator.response"
+ _usage = "cluster_picking.validator.response"
+
+ def find_batch(self):
+ return self._response_schema(
+ {
+ "confirm_start": {
+ "type": "dict",
+ "required": True,
+ "schema": self._schema_for_batch_details,
+ },
+ "start_line": {
+ "type": "dict",
+ "required": True,
+ "schema": self._schema_for_single_line_details,
+ },
+ "start": {"type": "dict", "required": False},
+ }
+ )
+
+ def select(self):
+ return self._response_schema(
+ {
+ "manual_selection": {"type": "dict", "required": False},
+ "confirm_start": {
+ "type": "dict",
+ "required": True,
+ "schema": self._schema_for_single_line_details,
+ },
+ "start_line": {
+ "type": "dict",
+ "required": True,
+ "schema": self._schema_for_single_line_details,
+ },
+ }
+ )
+
+ def unassign(self):
+ return self._response_schema({"start": {"type": "dict", "required": False}})
+
+ def scan_line(self):
+ return self._response_schema(
+ {
+ "start_line": {
+ "type": "dict",
+ "required": True,
+ "schema": self._schema_for_single_line_details,
+ },
+ "scan_destination": {
+ "type": "dict",
+ "required": True,
+ "schema": self._schema_for_single_line_details,
+ },
+ }
+ )
+
+ def scan_destination_pack(self):
+ return self._response_schema(
+ # when we still have lines to process
+ {
+ "start_line": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_single_line_details,
+ },
+ "zero_check": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_zero_check,
+ },
+ # when all lines have been processed and have same
+ # destination
+ "unload_all": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_all,
+ },
+ # when all lines have been processed and have different
+ # destinations
+ "unload_confirm_pack": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_single,
+ },
+ }
+ )
+
+ def prepare_unload(self):
+ return self._response_schema(
+ {
+ # when all lines have been processed and have same
+ # destination
+ "unload_all": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_all,
+ },
+ # when all lines have been processed and have different
+ # destinations
+ "unload_confirm_pack": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_single,
+ },
+ }
+ )
+
+ def is_zero(self):
+ return self._response_schema(
+ {
+ # when we still have lines to process
+ "start_line": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_single_line_details,
+ },
+ # when all lines have been processed and have same
+ # destination
+ "unload_all": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_all,
+ },
+ # when all lines have been processed and have different
+ # destinations
+ "unload_confirm_pack": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_single,
+ },
+ }
+ )
+
+ def skip_line(self):
+ return self._response_schema(
+ {
+ "start_line": {
+ "type": "dict",
+ "required": True,
+ "schema": self._schema_for_single_line_details,
+ }
+ }
+ )
+
+ def stock_issue(self):
+ return self._response_schema(
+ {
+ # when we still have lines to process
+ "start_line": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_single_line_details,
+ },
+ # when all lines have been processed and have same
+ # destination
+ "unload_all": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_all,
+ },
+ # when all lines have been processed and have different
+ # destinations
+ "unload_confirm_pack": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_single,
+ },
+ }
+ )
+
+ def change_pack_lot(self):
+ return self._response_schema(
+ {
+ "scan_destination": {
+ "type": "dict",
+ "required": True,
+ "schema": self._schema_for_single_line_details,
+ }
+ }
+ )
+
+ def set_destination_all(self):
+ return self._response_schema(
+ {
+ # if the batch still contain lines
+ "start_line": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_single_line_details,
+ },
+ "confirm_unload_all": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_all,
+ },
+ "start": {"type": "dict", "required": False},
+ }
+ )
+
+ def unload_split(self):
+ return self._response_schema(
+ {
+ "unload_confirm_pack": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_single,
+ }
+ }
+ )
+
+ def unload_scan_pack(self):
+ return self._response_schema(
+ {
+ "unload_confirm_pack": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_single,
+ },
+ "unload_set_destination": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_single,
+ },
+ }
+ )
+
+ def unload_scan_destination(self):
+ return self._response_schema(
+ {
+ "unload_confirm_pack": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_single,
+ },
+ "confirm_unload_set_destination": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_single,
+ },
+ "show_completion_info": {
+ "type": "dict",
+ "required": False,
+ "schema": self._schema_for_unload_single,
+ },
+ "start": {"type": "dict", "required": False},
+ "start_line": {
+ "type": "dict",
+ "required": True,
+ "schema": self._schema_for_single_line_details,
+ },
+ }
+ )
+
+ @property
+ def _schema_for_batch_details(self):
+ return {
+ # id is a stock.picking.batch
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "weight": {"type": "float", "nullable": False, "required": True},
+ "pickings": {
+ "type": "list",
+ "required": True,
+ "schema": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "move_line_count": {"required": True, "type": "integer"},
+ "origin": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ "partner": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ @property
+ def _schema_for_single_line_details(self):
+ return {
+ # id is a stock.move.line
+ "id": {"required": True, "type": "integer"},
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "origin": {"type": "string", "nullable": False, "required": True},
+ "note": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "batch": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "quantity": {"type": "float", "required": True},
+ "product": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "ref": {"type": "string", "nullable": False, "required": True},
+ "qty_available": {
+ "type": "float",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ "lot": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "ref": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ # TODO share parts of the schema?
+ "location_src": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "location_dst": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "pack": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ }
+
+ @property
+ def _schema_for_unload_all(self):
+ return {
+ # stock.batch.picking
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "location_dst": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ }
+
+ @property
+ def _schema_for_unload_single(self):
+ return {
+ # stock.move.line
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "location_dst": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ }
+
+ @property
+ def _schema_for_zero_check(self):
+ return {
+ # stock.move.line
+ "id": {"required": True, "type": "integer"},
+ "location_src": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ }
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index dd53151e59..6157f8e552 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -1,3 +1,5 @@
+from odoo.tests.common import Form
+
from .common import CommonCase
@@ -5,13 +7,44 @@ class ClusterPickingCase(CommonCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
+ cls.product_a = cls.env["product.product"].create(
+ {"name": "Product A", "type": "product"}
+ )
+ cls.product_b = cls.env["product.product"].create(
+ {"name": "Product B", "type": "product"}
+ )
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
cls.picking_type = cls.process.picking_type_ids
+ cls.main_batch = cls._create_batch_picking(cls.product_a)
def setUp(self):
super().setUp()
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="cluster_picking")
+
+ @classmethod
+ def _create_batch_picking(cls, product):
+ picking_form = Form(cls.env["stock.picking"])
+ picking_form.picking_type_id = cls.picking_type
+ picking_form.location_id = cls.stock_location
+ picking_form.location_dest_id = cls.packing_location
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = product
+ move.product_uom_qty = 1
+ picking = picking_form.save()
+ picking.action_confirm()
+ picking.action_assign()
+
+ batch_form = Form(cls.env["stock.picking.batch"])
+ batch_form.picking_ids.add(picking)
+ return batch_form.save()
+
+ # def test_list_manual_batch(self):
+ # """Test getting list of picking batches the user can work on"""
+ # # Simulate the client asking the list of picking batch it can
+ # # select after the user clicked on the "Manual Selection" button
+ # response = self.service.dispatch("/list_manual_selection")
+ # import pdb; pdb.set_trace()
From ec03f9b336b2ff1edb5cad84298a3810930eafb4 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 13 Feb 2020 13:34:07 +0100
Subject: [PATCH 084/940] Rename attribute 'state' to 'next_state' in REST API
---
shopfloor/services/cluster_picking.py | 2 +-
shopfloor/services/service.py | 9 +++--
shopfloor/services/single_pack_putaway.py | 32 ++++++++--------
shopfloor/services/single_pack_transfer.py | 40 +++++++++++---------
shopfloor/services/validator.py | 2 +-
shopfloor/tests/common.py | 8 ++--
shopfloor/tests/test_single_pack_putaway.py | 30 +++++++--------
shopfloor/tests/test_single_pack_transfer.py | 38 +++++++++----------
8 files changed, 84 insertions(+), 77 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 42211235fa..56aa024206 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -27,7 +27,7 @@ def select(self, batch_id):
def unassign(self, batch_id):
"""Unassign and reset to draft a started picking batch"""
- return self._response(state="start")
+ return self._response(next_state="start")
def scan_line(self, move_line_id, barcode):
"""Scan a location, a pack, a product or a lots
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 44a3f336fa..e7776129f3 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -62,13 +62,13 @@ def _get_output_validator(self, method_name):
)
return validator_component._get_validator(method_name)
- def _response(self, data=None, state=None, message=None):
+ def _response(self, data=None, next_state=None, message=None):
"""Base "envelope" for the responses
All the keys are optional.
:param data: dictionary of values
- :param state: string describing the next state that the client
+ :param next_state: string describing the next state that the client
application must reach
:param message: dictionary for the message to show in the client
application (see ``_response_schema`` for the keys)
@@ -76,8 +76,9 @@ def _response(self, data=None, state=None, message=None):
response = {}
if data:
response["data"] = data
- if state:
- response["state"] = state
+ if next_state:
+ response["next_state"] = next_state
+ # TODO ensure we have at least an empty dict for the next state
if message:
response["message"] = message
return response
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index ce74e746a6..96658a65bc 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -16,28 +16,30 @@ class SinglePackPutaway(Component):
# come from the same state
def _response_for_no_picking_type(self):
message = self.actions_for("message")
- return self._response(state="start", message=message.no_picking_type())
+ return self._response(next_state="start", message=message.no_picking_type())
def _response_for_several_picking_types(self):
message = self.actions_for("message")
- return self._response(state="start", message=message.several_picking_types())
+ return self._response(
+ next_state="start", message=message.several_picking_types()
+ )
def _response_for_package_not_found(self, barcode):
message = self.actions_for("message")
return self._response(
- state="start", message=message.package_not_found_for_barcode(barcode)
+ next_state="start", message=message.package_not_found_for_barcode(barcode)
)
def _response_for_forbidden_package(self, barcode, picking_type):
message = self.actions_for("message")
return self._response(
- state="start",
+ next_state="start",
message=message.package_not_allowed_in_src_location(barcode, picking_type),
)
def _response_for_forbidden_start(self, existing_operations):
return self._response(
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": _(
@@ -69,14 +71,14 @@ def _response_for_start_to_confirm(self, move_line, pack):
message = self.actions_for("message")
return self._response(
data=self._data_after_package_scanned(move_line, pack),
- state="confirm_start",
+ next_state="confirm_start",
message=message.already_running_ask_confirmation(),
)
def _response_for_start_success(self, move_line, pack):
message = self.actions_for("message")
return self._response(
- state="scan_location",
+ next_state="scan_location",
message=message.scan_destination(),
data=self._data_after_package_scanned(move_line, pack),
)
@@ -165,35 +167,35 @@ def _prepare_package_level(self, pack, move):
def _response_for_package_level_not_found(self):
message = self.actions_for("message")
- return self._response(state="start", message=message.operation_not_found())
+ return self._response(next_state="start", message=message.operation_not_found())
def _response_for_move_canceled_elsewhere(self):
message = self.actions_for("message")
return self._response(
- state="start", message=message.operation_has_been_canceled_elsewhere()
+ next_state="start", message=message.operation_has_been_canceled_elsewhere()
)
def _response_for_location_not_found(self):
message = self.actions_for("message")
return self._response(
- state="scan_location", message=message.no_location_found()
+ next_state="scan_location", message=message.no_location_found()
)
def _response_for_forbidden_location(self):
message = self.actions_for("message")
return self._response(
- state="scan_location", message=message.dest_location_not_allowed()
+ next_state="scan_location", message=message.dest_location_not_allowed()
)
def _response_for_location_need_confirm(self):
message = self.actions_for("message")
return self._response(
- state="confirm_location", message=message.need_confirmation()
+ next_state="confirm_location", message=message.need_confirmation()
)
def _response_for_validate_success(self):
message = self.actions_for("message")
- return self._response(state="start", message=message.confirm_pack_moved())
+ return self._response(next_state="start", message=message.confirm_pack_moved())
def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
@@ -228,12 +230,12 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
def _response_for_move_already_processed(self):
message = self.actions_for("message")
- return self._response(state="start", message=message.already_done())
+ return self._response(next_state="start", message=message.already_done())
def _response_for_confirm_move_cancellation(self):
message = self.actions_for("message")
return self._response(
- state="start", message=message.confirm_canceled_scan_next_pack()
+ next_state="start", message=message.confirm_canceled_scan_next_pack()
)
def cancel(self, package_level_id):
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 832321b17c..65e465decd 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -17,36 +17,38 @@ class SinglePackTransfer(Component):
def _response_for_empty_location(self, location):
message = self.actions_for("message")
return self._response(
- state="start", message=message.no_pack_in_location(location)
+ next_state="start", message=message.no_pack_in_location(location)
)
def _response_for_several_packages(self, location):
message = self.actions_for("message")
return self._response(
- state="start", message=message.several_packs_in_location(location)
+ next_state="start", message=message.several_packs_in_location(location)
)
def _response_for_package_not_found(self, barcode):
message = self.actions_for("message")
return self._response(
- state="start", message=message.package_not_found_for_barcode(barcode)
+ next_state="start", message=message.package_not_found_for_barcode(barcode)
)
def _response_for_forbidden_package(self, barcode, picking_type):
message = self.actions_for("message")
return self._response(
- state="start",
+ next_state="start",
message=message.package_not_allowed_in_src_location(barcode, picking_type),
)
def _response_for_several_picking_types(self):
message = self.actions_for("message")
- return self._response(state="start", message=message.several_picking_types())
+ return self._response(
+ next_state="start", message=message.several_picking_types()
+ )
def _response_for_operation_not_found(self, pack):
message = self.actions_for("message")
return self._response(
- state="start", message=message.no_pending_operation_for_pack(pack)
+ next_state="start", message=message.no_pending_operation_for_pack(pack)
)
def _data_after_package_scanned(self, move_line, pack):
@@ -66,7 +68,7 @@ def _data_after_package_scanned(self, move_line, pack):
def _response_for_start_to_confirm(self, move_line, pack):
message = self.actions_for("message")
return self._response(
- state="confirm_start",
+ next_state="confirm_start",
message=message.already_running_ask_confirmation(),
data=self._data_after_package_scanned(move_line, pack),
)
@@ -74,7 +76,7 @@ def _response_for_start_to_confirm(self, move_line, pack):
def _response_for_start_success(self, move_line, pack):
message = self.actions_for("message")
return self._response(
- state="scan_location",
+ next_state="scan_location",
message=message.scan_destination(),
data=self._data_after_package_scanned(move_line, pack),
)
@@ -125,38 +127,40 @@ def start(self, barcode):
def _response_for_package_level_not_found(self):
message = self.actions_for("message")
- return self._response(state="start", message=message.operation_not_found())
+ return self._response(next_state="start", message=message.operation_not_found())
def _response_for_move_canceled_elsewhere(self):
message = self.actions_for("message")
return self._response(
- state="start", message=message.operation_has_been_canceled_elsewhere()
+ next_state="start", message=message.operation_has_been_canceled_elsewhere()
)
def _response_for_location_not_found(self):
message = self.actions_for("message")
return self._response(
- state="scan_location", message=message.no_location_found()
+ next_state="scan_location", message=message.no_location_found()
)
def _response_for_forbidden_location(self):
message = self.actions_for("message")
return self._response(
- state="scan_location", message=message.dest_location_not_allowed()
+ next_state="scan_location", message=message.dest_location_not_allowed()
)
def _response_for_location_need_confirm(self):
message = self.actions_for("message")
return self._response(
- state="confirm_location", message=message.need_confirmation()
+ next_state="confirm_location", message=message.need_confirmation()
)
def _response_for_validate_success(self, last=False):
message = self.actions_for("message")
- state = "start"
+ next_state = "start"
if last:
- state = "show_completion_info"
- return self._response(state=state, message=message.confirm_pack_moved())
+ next_state = "show_completion_info"
+ return self._response(
+ next_state=next_state, message=message.confirm_pack_moved()
+ )
def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
@@ -205,12 +209,12 @@ def cancel(self, package_level_id):
def _response_for_move_already_processed(self):
message = self.actions_for("message")
- return self._response(state="start", message=message.already_done())
+ return self._response(next_state="start", message=message.already_done())
def _response_for_confirm_cancel(self):
message = self.actions_for("message")
return self._response(
- state="start", message=message.confirm_canceled_scan_next_pack()
+ next_state="start", message=message.confirm_canceled_scan_next_pack()
)
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
index 0ecee638b7..48d8d44e96 100644
--- a/shopfloor/services/validator.py
+++ b/shopfloor/services/validator.py
@@ -29,7 +29,7 @@ def _response_schema(self, data_schema=None):
data_schema = {}
return {
"data": {"type": "dict", "required": False, "schema": data_schema},
- "state": {"type": "string", "required": False},
+ "next_state": {"type": "string", "required": False},
"message": {
"type": "dict",
"required": False,
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 52b4d05aa5..45bd0c4cf3 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -62,7 +62,7 @@ def setUpClassVars(cls):
cls.shelf1 = cls.env.ref("stock.stock_location_components")
cls.shelf2 = cls.env.ref("stock.stock_location_14")
- def assert_response(self, response, state=None, message=None, data=None):
+ def assert_response(self, response, next_state=None, message=None, data=None):
"""Assert a response from the webservice
The data and message dictionaries are checked using
@@ -70,8 +70,8 @@ def assert_response(self, response, state=None, message=None, data=None):
value.
"""
expected = {}
- if state:
- expected["state"] = state
+ if next_state:
+ expected["next_state"] = next_state
if message:
expected["message"] = message
if data:
@@ -95,7 +95,7 @@ def assert_dict(self, current, expected):
"message_type": self.ANY,
"message": self.ANY,
},
- "state": self.ANY,
+ "next_state": self.ANY,
}
Note: if ``self.ANY`` is used, the key must exist in the dictionary
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 2983419727..8a0cada93a 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -75,7 +75,7 @@ def test_start(self):
)
self.assert_response(
response,
- state="scan_location",
+ next_state="scan_location",
message={
"message_type": "info",
"message": "Scan the destination location",
@@ -108,7 +108,7 @@ def test_start_no_package_for_barcode(self):
response = self.service.dispatch("start", params=params)
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "The package NOTHING_SHOULD_EXIST_WITH: 👀 doesn't exist",
@@ -134,7 +134,7 @@ def test_start_package_not_in_src_location(self):
response = self.service.dispatch("start", params=params)
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "You cannot work on a package (%s) outside of location: %s"
@@ -175,7 +175,7 @@ def test_start_move_in_different_picking_type(self):
response = self.service.dispatch("start", params=params)
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "An operation exists in Delivery Orders %s. You cannot"
@@ -208,7 +208,7 @@ def test_start_move_already_exist(self):
response = self.service.dispatch("start", params=params)
self.assert_response(
response,
- state="confirm_start",
+ next_state="confirm_start",
message={
"message_type": "warning",
"message": "Operation's already running."
@@ -274,7 +274,7 @@ def test_validate(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "info",
"message": "The pack has been moved, you can scan a new pack.",
@@ -304,7 +304,7 @@ def test_validate_not_found(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "This operation does not exist anymore.",
@@ -336,7 +336,7 @@ def test_validate_location_not_found(self):
self.assert_response(
response,
- state="scan_location",
+ next_state="scan_location",
message={
"message_type": "error",
"message": "No location found for this barcode.",
@@ -372,7 +372,7 @@ def test_validate_location_forbidden(self):
self.assert_response(
response,
- state="scan_location",
+ next_state="scan_location",
message={"message_type": "error", "message": "You cannot place it here"},
)
@@ -407,7 +407,7 @@ def test_validate_location_to_confirm(self):
self.assert_response(
response,
- state="confirm_location",
+ next_state="confirm_location",
message={"message_type": "warning", "message": "Are you sure?"},
)
@@ -448,7 +448,7 @@ def test_validate_location_with_confirm(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "info",
"message": "The pack has been moved, you can scan a new pack.",
@@ -495,7 +495,7 @@ def test_cancel(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "info",
"message": "Canceled, you can scan a new pack.",
@@ -537,7 +537,7 @@ def test_cancel_already_canceled(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "info",
"message": "Canceled, you can scan a new pack.",
@@ -576,7 +576,7 @@ def test_cancel_already_done(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={"message_type": "info", "message": "Operation already processed."},
)
@@ -590,7 +590,7 @@ def test_cancel_not_found(self):
response = self.service.dispatch("cancel", params={"package_level_id": -1})
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "This operation does not exist anymore.",
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index dff4999c3d..fd02731953 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -92,7 +92,7 @@ def test_start(self):
self.assertTrue(package_level.is_done)
self.assert_response(
response,
- state="scan_location",
+ next_state="scan_location",
message={
"message_type": "info",
"message": "Scan the destination location",
@@ -130,7 +130,7 @@ def test_start_no_operation(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "No pending operation for package {}.".format(
@@ -154,7 +154,7 @@ def test_start_barcode_not_known(self):
response = self.service.dispatch("start", params=params)
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "The package THIS_BARCODE_DOES_NOT_EXIST" " doesn't exist",
@@ -188,7 +188,7 @@ def test_start_pack_from_location(self):
# screen, so it found the pack. The details are already
# checked in the test_start test.
response,
- state="scan_location",
+ next_state="scan_location",
message=self.ANY,
data=self.ANY,
)
@@ -212,7 +212,7 @@ def test_start_pack_from_location_empty(self):
response = self.service.dispatch("start", params=params)
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "Location %s doesn't contain any package."
@@ -250,7 +250,7 @@ def test_start_pack_from_location_several_packs(self):
response = self.service.dispatch("start", params=params)
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "Several packages found in %s, please scan a package."
@@ -276,7 +276,7 @@ def test_start_pack_outside_of_location(self):
response = self.service.dispatch("start", params=params)
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "You cannot work on a package (%s) outside of location: %s"
@@ -315,7 +315,7 @@ def test_start_already_started(self):
self.assert_response(
response,
- state="confirm_start",
+ next_state="confirm_start",
message={
"message_type": "warning",
"message": "Operation's already running."
@@ -359,7 +359,7 @@ def test_validate(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "info",
"message": "The pack has been moved, you can scan a new pack.",
@@ -414,7 +414,7 @@ def test_validate_completion_info(self):
self.assert_response(
response,
- state="show_completion_info",
+ next_state="show_completion_info",
message={
"message_type": "info",
"message": "The pack has been moved, you can scan a new pack.",
@@ -435,7 +435,7 @@ def test_validate_not_found(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "This operation does not exist anymore.",
@@ -467,7 +467,7 @@ def test_validate_location_not_found(self):
self.assert_response(
response,
- state="scan_location",
+ next_state="scan_location",
message={
"message_type": "error",
"message": "No location found for this barcode.",
@@ -503,7 +503,7 @@ def test_validate_location_forbidden(self):
self.assert_response(
response,
- state="scan_location",
+ next_state="scan_location",
message={"message_type": "error", "message": "You cannot place it here"},
)
@@ -538,7 +538,7 @@ def test_validate_location_to_confirm(self):
self.assert_response(
response,
- state="confirm_location",
+ next_state="confirm_location",
message={"message_type": "warning", "message": "Are you sure?"},
)
@@ -579,7 +579,7 @@ def test_validate_location_with_confirm(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "info",
"message": "The pack has been moved, you can scan a new pack.",
@@ -625,7 +625,7 @@ def test_cancel(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "info",
"message": "Canceled, you can scan a new pack.",
@@ -667,7 +667,7 @@ def test_cancel_already_canceled(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "info",
"message": "Canceled, you can scan a new pack.",
@@ -706,7 +706,7 @@ def test_cancel_already_done(self):
self.assert_response(
response,
- state="start",
+ next_state="start",
message={"message_type": "info", "message": "Operation already processed."},
)
@@ -720,7 +720,7 @@ def test_cancel_not_found(self):
response = self.service.dispatch("cancel", params={"package_level_id": -1})
self.assert_response(
response,
- state="start",
+ next_state="start",
message={
"message_type": "error",
"message": "This operation does not exist anymore.",
From cbd1aba01747845fca6f41fb9614c6f887267f35 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 13 Feb 2020 16:20:38 +0100
Subject: [PATCH 085/940] Rework how responses include data needed for the next
state
The 'validator.response' components have to define a `_states()` method
that gives the match between a state and the schema a state requires.
Validators for methods which can make the client transition must declare
the list of possible next states using `next_states=["state_a",
"state_b"]` (which must exists in the `_states()` method).
When an endpoint returns data for a state, the data will now be enclosed
in a key with the same name as the state, this is in order to support
polymorphism in schemas (an endpoint being able to return different data
depending on the next state).
General idea of a schema for a method that changes state (data may vary,
in this example, next_state will be one of "confirm_start", "start",
"scan_location"):
{
message {
message_type* string
message* string
}
next_state string
data {
confirm_start {...}
start {...}
scan_location {...}
}
}
General idea of a schema for a generic method (data may vary):
{
message {
message_type* string
message* string
}
data {
size* integer
records* integer
}
}
---
shopfloor/services/cluster_picking.py | 252 +++++--------------
shopfloor/services/service.py | 26 +-
shopfloor/services/single_pack_putaway.py | 100 +++++---
shopfloor/services/single_pack_transfer.py | 112 ++++++---
shopfloor/services/validator.py | 87 ++++++-
shopfloor/tests/common.py | 11 +-
shopfloor/tests/test_cluster_picking.py | 4 +
shopfloor/tests/test_picking_batch.py | 4 +
shopfloor/tests/test_single_pack_putaway.py | 11 +-
shopfloor/tests/test_single_pack_transfer.py | 6 +
10 files changed, 329 insertions(+), 284 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 56aa024206..db625c25cc 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -167,254 +167,132 @@ class ShopfloorClusterPickingValidatorResponse(Component):
_name = "shopfloor.cluster_picking.validator.response"
_usage = "cluster_picking.validator.response"
+ def _states(self):
+ """List of possible next states
+
+ With the schema of the data send to the client to transition
+ to the next state.
+ """
+ return {
+ "confirm_start": self._schema_for_batch_details,
+ "start_line": self._schema_for_single_line_details,
+ "start": {},
+ "manual_selection": {},
+ "scan_destination": self._schema_for_single_line_details,
+ "zero_check": self._schema_for_zero_check,
+ "unload_all": self._schema_for_unload_all,
+ "confirm_unload_all": self._schema_for_unload_all,
+ "unload_confirm_pack": self._schema_for_unload_single,
+ "unload_set_destination": self._schema_for_unload_single,
+ "confirm_unload_set_destination": self._schema_for_unload_single,
+ "show_completion_info": self._schema_for_unload_single,
+ }
+
def find_batch(self):
return self._response_schema(
- {
- "confirm_start": {
- "type": "dict",
- "required": True,
- "schema": self._schema_for_batch_details,
- },
- "start_line": {
- "type": "dict",
- "required": True,
- "schema": self._schema_for_single_line_details,
- },
- "start": {"type": "dict", "required": False},
- }
+ next_states=["confirm_start", "start_line", "start"]
)
def select(self):
- return self._response_schema(
- {
- "manual_selection": {"type": "dict", "required": False},
- "confirm_start": {
- "type": "dict",
- "required": True,
- "schema": self._schema_for_single_line_details,
- },
- "start_line": {
- "type": "dict",
- "required": True,
- "schema": self._schema_for_single_line_details,
- },
- }
- )
+ return self._response_schema(next_states=["manual_selection", "confirm_start"])
def unassign(self):
- return self._response_schema({"start": {"type": "dict", "required": False}})
+ return self._response_schema(next_states=["start"])
def scan_line(self):
- return self._response_schema(
- {
- "start_line": {
- "type": "dict",
- "required": True,
- "schema": self._schema_for_single_line_details,
- },
- "scan_destination": {
- "type": "dict",
- "required": True,
- "schema": self._schema_for_single_line_details,
- },
- }
- )
+ return self._response_schema(next_states=["start_line", "scan_destination"])
def scan_destination_pack(self):
return self._response_schema(
- # when we still have lines to process
- {
- "start_line": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_single_line_details,
- },
- "zero_check": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_zero_check,
- },
+ next_states=[
+ # when we still have lines to process
+ "start_line",
+ # when the source location is empty
+ "zero_check",
# when all lines have been processed and have same
# destination
- "unload_all": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_all,
- },
+ "unload_all",
# when all lines have been processed and have different
# destinations
- "unload_confirm_pack": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_single,
- },
- }
+ "unload_confirm_pack",
+ ]
)
def prepare_unload(self):
return self._response_schema(
- {
+ next_states=[
# when all lines have been processed and have same
# destination
- "unload_all": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_all,
- },
+ "unload_all",
# when all lines have been processed and have different
# destinations
- "unload_confirm_pack": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_single,
- },
- }
+ "unload_confirm_pack",
+ ]
)
def is_zero(self):
return self._response_schema(
- {
+ next_states=[
# when we still have lines to process
- "start_line": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_single_line_details,
- },
+ "start_line",
# when all lines have been processed and have same
# destination
- "unload_all": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_all,
- },
+ "unload_all",
# when all lines have been processed and have different
# destinations
- "unload_confirm_pack": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_single,
- },
- }
+ "unload_confirm_pack",
+ ]
)
def skip_line(self):
- return self._response_schema(
- {
- "start_line": {
- "type": "dict",
- "required": True,
- "schema": self._schema_for_single_line_details,
- }
- }
- )
+ return self._response_schema(next_states=["start_line"])
def stock_issue(self):
return self._response_schema(
- {
+ next_states=[
# when we still have lines to process
- "start_line": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_single_line_details,
- },
+ "start_line",
# when all lines have been processed and have same
# destination
- "unload_all": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_all,
- },
+ "unload_all",
# when all lines have been processed and have different
# destinations
- "unload_confirm_pack": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_single,
- },
- }
+ "unload_confirm_pack",
+ ]
)
def change_pack_lot(self):
- return self._response_schema(
- {
- "scan_destination": {
- "type": "dict",
- "required": True,
- "schema": self._schema_for_single_line_details,
- }
- }
- )
+ return self._response_schema(next_states=["scan_destination"])
def set_destination_all(self):
return self._response_schema(
- {
+ next_states=[
# if the batch still contain lines
- "start_line": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_single_line_details,
- },
- "confirm_unload_all": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_all,
- },
- "start": {"type": "dict", "required": False},
- }
+ "start_line",
+ # different destination to confirm
+ "confirm_unload_all",
+ # batch finished
+ "start",
+ ]
)
def unload_split(self):
- return self._response_schema(
- {
- "unload_confirm_pack": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_single,
- }
- }
- )
+ return self._response_schema(next_states=["unload_confirm_pack"])
def unload_scan_pack(self):
return self._response_schema(
- {
- "unload_confirm_pack": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_single,
- },
- "unload_set_destination": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_single,
- },
- }
+ next_states=["unload_confirm_pack", "unload_set_destination"]
)
def unload_scan_destination(self):
return self._response_schema(
- {
- "unload_confirm_pack": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_single,
- },
- "confirm_unload_set_destination": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_single,
- },
- "show_completion_info": {
- "type": "dict",
- "required": False,
- "schema": self._schema_for_unload_single,
- },
- "start": {"type": "dict", "required": False},
- "start_line": {
- "type": "dict",
- "required": True,
- "schema": self._schema_for_single_line_details,
- },
- }
+ next_states=[
+ "unload_confirm_pack",
+ "confirm_unload_set_destination",
+ "show_completion_info",
+ "start",
+ "start_line",
+ ]
)
@property
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index e7776129f3..9290fecdb7 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -67,20 +67,36 @@ def _response(self, data=None, next_state=None, message=None):
All the keys are optional.
- :param data: dictionary of values
+ :param data: dictionary of values, when a next_state is provided,
+ the data is enclosed in a key of the same name (to support polymorphism
+ in the schema)
:param next_state: string describing the next state that the client
application must reach
:param message: dictionary for the message to show in the client
application (see ``_response_schema`` for the keys)
"""
response = {}
- if data:
- response["data"] = data
if next_state:
- response["next_state"] = next_state
- # TODO ensure we have at least an empty dict for the next state
+ # data for a state is always enclosed in a key with the name
+ # of the state, so an endpoint can return to different states
+ # that need different data: the schema can be different for
+ # every state this way
+ response.update(
+ {
+ # ensure we have an empty dict when the state
+ # does not need any data, so the client does not need
+ # to check this
+ "data": {next_state: data or {}},
+ "next_state": next_state,
+ }
+ )
+
+ elif data:
+ response["data"] = data
+
if message:
response["message"] = message
+
return response
def _get_openapi_default_parameters(self):
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 96658a65bc..b365f644b4 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -175,16 +175,20 @@ def _response_for_move_canceled_elsewhere(self):
next_state="start", message=message.operation_has_been_canceled_elsewhere()
)
- def _response_for_location_not_found(self):
+ def _response_for_location_not_found(self, move_line, pack):
message = self.actions_for("message")
return self._response(
- next_state="scan_location", message=message.no_location_found()
+ next_state="scan_location",
+ message=message.no_location_found(),
+ data=self._data_after_package_scanned(move_line, pack),
)
- def _response_for_forbidden_location(self):
+ def _response_for_forbidden_location(self, move_line, pack):
message = self.actions_for("message")
return self._response(
- next_state="scan_location", message=message.dest_location_not_allowed()
+ next_state="scan_location",
+ message=message.dest_location_not_allowed(),
+ data=self._data_after_package_scanned(move_line, pack),
)
def _response_for_location_need_confirm(self):
@@ -206,15 +210,16 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if not package.exists():
return self._response_for_package_level_not_found()
- move = package.move_line_ids[0].move_id
+ move_line = package.move_line_ids[0]
+ move = move_line.move_id
if not pack_transfer.is_move_state_valid(move):
return self._response_for_move_canceled_elsewhere()
scanned_location = search.location_from_scan(location_barcode)
if not scanned_location:
- return self._response_for_location_not_found()
+ return self._response_for_location_not_found(move_line, package.package_id)
if not pack_transfer.is_dest_location_valid(move, scanned_location):
- return self._response_for_forbidden_location()
+ return self._response_for_forbidden_location(move_line, package.package_id)
if pack_transfer.is_dest_location_to_confirm(move, scanned_location):
if confirmation:
@@ -281,44 +286,63 @@ class SinglePackPutawayValidatorResponse(Component):
_name = "shopfloor.single.pack.putaway.validator.response"
_usage = "single_pack_putaway.validator.response"
+ def _states(self):
+ """List of possible next states
+
+ With the schema of the data send to the client to transition
+ to the next state.
+ """
+ return {
+ "start": {},
+ "confirm_start": self._schema_for_location,
+ "scan_location": self._schema_for_location,
+ "confirm_location": {},
+ }
+
def cancel(self):
- return self._response_schema()
+ return self._response_schema(next_states=["start"])
def validate(self):
- return self._response_schema()
+ return self._response_schema(
+ next_states=["scan_location", "start", "confirm_location"]
+ )
def start(self):
return self._response_schema(
- {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "location_src": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
+ next_states=["confirm_start", "start", "scan_location"]
+ )
+
+ @property
+ def _schema_for_location(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "location_src": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
- "location_dst": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
+ },
+ "location_dst": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
- "product": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
+ },
+ "product": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
- "picking": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
+ },
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
},
- }
- )
+ },
+ }
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 65e465decd..4cb233bc69 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -135,16 +135,20 @@ def _response_for_move_canceled_elsewhere(self):
next_state="start", message=message.operation_has_been_canceled_elsewhere()
)
- def _response_for_location_not_found(self):
+ def _response_for_location_not_found(self, move_line, pack):
message = self.actions_for("message")
return self._response(
- next_state="scan_location", message=message.no_location_found()
+ next_state="scan_location",
+ message=message.no_location_found(),
+ data=self._data_after_package_scanned(move_line, pack),
)
- def _response_for_forbidden_location(self):
+ def _response_for_forbidden_location(self, move_line, pack):
message = self.actions_for("message")
return self._response(
- next_state="scan_location", message=message.dest_location_not_allowed()
+ next_state="scan_location",
+ message=message.dest_location_not_allowed(),
+ data=self._data_after_package_scanned(move_line, pack),
)
def _response_for_location_need_confirm(self):
@@ -172,15 +176,16 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if not package.exists():
return self._response_for_package_level_not_found()
- move = package.move_line_ids[0].move_id
+ move_line = package.move_line_ids[0]
+ move = move_line.move_id
if not pack_transfer.is_move_state_valid(move):
return self._response_for_move_canceled_elsewhere()
scanned_location = search.location_from_scan(location_barcode)
if not scanned_location:
- return self._response_for_location_not_found()
+ return self._response_for_location_not_found(move_line, package.package_id)
if not pack_transfer.is_dest_location_valid(move, scanned_location):
- return self._response_for_forbidden_location()
+ return self._response_for_forbidden_location(move_line, package.package_id)
if pack_transfer.is_dest_location_to_confirm(move, scanned_location):
if confirmation:
@@ -248,44 +253,69 @@ class SinglePackTransferValidatorResponse(Component):
_name = "shopfloor.single.pack.transfer.validator.response"
_usage = "single_pack_transfer.validator.response"
+ def _states(self):
+ """List of possible next states
+
+ With the schema of the data send to the client to transition
+ to the next state.
+ """
+ return {
+ "start": {},
+ "confirm_start": self._schema_for_location,
+ "scan_location": self._schema_for_location,
+ "confirm_location": {},
+ "show_completion_info": {},
+ }
+
def start(self):
return self._response_schema(
- {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "location_src": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "location_dst": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "product": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "picking": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- }
+ next_states=["confirm_start", "start", "scan_location"]
)
def cancel(self):
- return self._response_schema()
+ return self._response_schema(next_states=["start"])
def validate(self):
- return self._response_schema()
+ return self._response_schema(
+ next_states=[
+ "scan_location",
+ "start",
+ "confirm_location",
+ "show_completion_info",
+ ]
+ )
+
+ @property
+ def _schema_for_location(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "location_src": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "location_dst": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "product": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ }
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
index 48d8d44e96..67b2b16b38 100644
--- a/shopfloor/services/validator.py
+++ b/shopfloor/services/validator.py
@@ -11,25 +11,70 @@ class BaseShopfloorValidator(AbstractComponent):
class BaseShopfloorValidatorResponse(AbstractComponent):
- """Base class for Validator for Responses"""
+ """Base class for Validator for Responses
+
+ When an endpoint returns data for a state, the data is enclosed
+ in a key with the same name as the state, this is in order to support
+ polymorphism in schemas (an endpoint being able to return different data
+ depending on the next state).
+
+ General idea of a schema for a method that changes state (data may vary,
+ in this example, next_state will be one of "confirm_start", "start",
+ "scan_location"):
+
+ {
+ message {
+ message_type* string
+ message* string
+ }
+ next_state string
+ data {
+ confirm_start {...}
+ start {...}
+ scan_location {...}
+ }
+ }
+
+ General idea of a schema for a generic method (data may vary):
+
+ {
+ message {
+ message_type* string
+ message* string
+ }
+ data {
+ size* integer
+ records* integer
+ }
+ }
+
+ """
_inherit = "base.rest.service"
_name = "base.shopfloor.validator.response"
_collection = "shopfloor.service"
_is_rest_service_component = False
- def _response_schema(self, data_schema=None):
+ def _states(self):
+ """List of possible next states
+
+ With the schema of the data send to the client to transition
+ to the next state.
+ """
+ return {}
+
+ def _response_schema(self, data_schema=None, next_states=None):
"""Schema for the return validator
Must be used for the schema of all responses.
The "data" part can be customized and is optional,
it must be a dictionary.
+
+ next_states is a list of allowed states to which the client
+ can transition. The schema of the data needed for every state
+ of the list must be defined in the ``_states`` method.
"""
- if not data_schema:
- data_schema = {}
- return {
- "data": {"type": "dict", "required": False, "schema": data_schema},
- "next_state": {"type": "string", "required": False},
+ response_schema = {
"message": {
"type": "dict",
"required": False,
@@ -41,5 +86,31 @@ def _response_schema(self, data_schema=None):
},
"message": {"type": "string", "required": True},
},
- },
+ }
+ }
+ if not data_schema:
+ data_schema = {}
+
+ if next_states:
+ states_schemas = self._states()
+ unknown_states = set(next_states) - states_schemas.keys()
+ if unknown_states:
+ raise ValueError(
+ "states {!r} are not defined in _states".format(unknown_states)
+ )
+
+ data_schema = data_schema.copy()
+ data_schema.update(
+ {
+ state: {"type": "dict", "schema": states_schemas[state]}
+ for state in next_states
+ }
+ )
+ response_schema["next_state"] = {"type": "string", "required": False}
+
+ response_schema["data"] = {
+ "type": "dict",
+ "required": False,
+ "schema": data_schema,
}
+ return response_schema
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 45bd0c4cf3..2c7aed84fa 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -70,11 +70,13 @@ def assert_response(self, response, next_state=None, message=None, data=None):
value.
"""
expected = {}
- if next_state:
- expected["next_state"] = next_state
if message:
expected["message"] = message
- if data:
+ if next_state:
+ expected.update(
+ {"next_state": next_state, "data": {next_state: data or {}}}
+ )
+ elif data:
expected["data"] = data
self.assert_dict(response, expected)
@@ -122,7 +124,8 @@ def assert_dict(self, current, expected):
self.assertDictEqual(
node_values,
node_expected,
- "\n\nNode's specs:\n%s" % (pformat(node_original_expected)),
+ "\n\nActual:\n%s"
+ "\n\nExpected:\n%s" % (pformat(current), pformat(expected)),
)
def _update_qty_in_location(self, location, product, quantity):
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 6157f8e552..7fd1d38430 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -42,6 +42,10 @@ def _create_batch_picking(cls, product):
batch_form.picking_ids.add(picking)
return batch_form.save()
+ def test_to_openapi(self):
+ # will raise if it fails to generate the openapi specs
+ self.service.to_openapi()
+
# def test_list_manual_batch(self):
# """Test getting list of picking batches the user can work on"""
# # Simulate the client asking the list of picking batch it can
diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py
index 62e4f70a38..374261de59 100644
--- a/shopfloor/tests/test_picking_batch.py
+++ b/shopfloor/tests/test_picking_batch.py
@@ -51,6 +51,10 @@ def _create_picking_batch(cls, product):
batch_form.picking_ids.add(picking)
return batch_form.save()
+ def test_to_openapi(self):
+ # will raise if it fails to generate the openapi specs
+ self.service.to_openapi()
+
def test_search_empty(self):
"""No batch is available"""
# Simulate the client asking the list of picking batch
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 8a0cada93a..640fc1aa7a 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -38,6 +38,10 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="single_pack_putaway")
+ def test_to_openapi(self):
+ # will raise if it fails to generate the openapi specs
+ self.service.to_openapi()
+
def test_start(self):
"""Test the happy path for single pack putaway /start endpoint
@@ -60,9 +64,10 @@ def test_start(self):
# Simulate the client scanning a package's barcode, which
# in turns should start the operation in odoo
response = self.service.dispatch("start", params=params)
+ state_data = response["data"]["scan_location"]
# Checks:
- package_level = self.env["stock.package_level"].browse(response["data"]["id"])
+ package_level = self.env["stock.package_level"].browse(state_data["id"])
move_line = package_level.move_line_ids
move = move_line.move_id
@@ -206,6 +211,7 @@ def test_start_move_already_exist(self):
params = {"barcode": barcode}
response = self.service.dispatch("start", params=params)
+
self.assert_response(
response,
next_state="confirm_start",
@@ -341,6 +347,7 @@ def test_validate_location_not_found(self):
"message_type": "error",
"message": "No location found for this barcode.",
},
+ data=self.ANY,
)
def test_validate_location_forbidden(self):
@@ -374,6 +381,7 @@ def test_validate_location_forbidden(self):
response,
next_state="scan_location",
message={"message_type": "error", "message": "You cannot place it here"},
+ data=self.ANY,
)
def test_validate_location_to_confirm(self):
@@ -530,6 +538,7 @@ def test_cancel_already_canceled(self):
response = self.service.dispatch(
"cancel", params={"package_level_id": package_level.id}
)
+
self.assertRecordValues(move, [{"state": "cancel"}])
self.assertRecordValues(picking, [{"state": "cancel"}])
self.assertFalse(package_level.move_line_ids)
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index fd02731953..acbd4aef88 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -61,6 +61,10 @@ def _simulate_started(self):
package_level.is_done = True
return package_level
+ def test_to_openapi(self):
+ # will raise if it fails to generate the openapi specs
+ self.service.to_openapi()
+
def test_start(self):
"""Test the happy path for single pack transfer /start endpoint
@@ -468,6 +472,7 @@ def test_validate_location_not_found(self):
self.assert_response(
response,
next_state="scan_location",
+ data=self.ANY,
message={
"message_type": "error",
"message": "No location found for this barcode.",
@@ -504,6 +509,7 @@ def test_validate_location_forbidden(self):
self.assert_response(
response,
next_state="scan_location",
+ data=self.ANY,
message={"message_type": "error", "message": "You cannot place it here"},
)
From 16101ecb321380436c4c490c96161c9dd427b308 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 14 Feb 2020 15:49:56 +0100
Subject: [PATCH 086/940] Document and add details to cluster picking endpoints
---
shopfloor/models/__init__.py | 1 +
shopfloor/models/stock_picking_batch.py | 12 +
shopfloor/services/cluster_picking.py | 390 +++++++++++++++++++++---
shopfloor/services/service.py | 8 +
4 files changed, 376 insertions(+), 35 deletions(-)
create mode 100644 shopfloor/models/stock_picking_batch.py
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index d0299a28b9..b39ee610aa 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -4,4 +4,5 @@
from . import stock_picking_type
from . import shopfloor_profile
from . import stock_location
+from . import stock_picking_batch
from . import res_users
diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py
new file mode 100644
index 0000000000..4101de4da5
--- /dev/null
+++ b/shopfloor/models/stock_picking_batch.py
@@ -0,0 +1,12 @@
+from odoo import fields, models
+
+
+class StockPickingBatch(models.Model):
+ _inherit = "stock.picking.batch"
+
+ cluster_picking_unload_all = fields.Boolean(
+ default=False,
+ copy=False,
+ help="Technical field. Indicates if a batch is destination is"
+ " asked once for all lines or for every line.",
+ )
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index db625c25cc..9165382afb 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1,9 +1,64 @@
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
+from .service import to_float
+
class ClusterPicking(Component):
- """Methods for the Cluster Picking Process"""
+ """Methods for the Cluster Picking Process
+
+ The goal of this process is to do the pickings for a Picking Batch, for
+ several customers at once.
+ The process assumes that picking batch records already exist.
+
+ At first, a user gets automatically a batch to work on (assigned to them),
+ or can select one from a list.
+
+ The process has 2 main phases, which can be done one after the other or a
+ bit of both. The first one is picking goods and put them in a roller-cage.
+
+ First phase, picking:
+
+ * Pick a good (move line) from a source location, scan it to confirm it's
+ the expected one
+ * Scan the label of a Bin (package) in a roller-cage, put the good inside
+ (physically). Once the first move line of a picking has been scanned, the
+ screen will show the same destination package for all the other lines of
+ the picking to help the user grouping goods together, and will prevent
+ lines from other pickings to be put in the same destination package.
+ * If odoo thinks a source location is empty after picking the goods, a
+ "zero check" is done: it asks the user to confirm if it is empty or not
+ * Repeat until the end of the batch or the roller-cage is full (there is
+ button to declare this)
+
+ Second phase, unload to destination:
+
+ * If all the goods (move lines) in the roller-cage go to the same destination,
+ a screen asking a single barcode for the destination is shown
+ * Otherwise, the user has to scan one destination per good.
+ * If all the goods are supposed to go to the same destination but user doesn't
+ want or can't, a "split" allows to reach the screen to scan one destination
+ per good.
+ * When everything has a destination set and the batch is not finished yet,
+ the user goes to the first phase of pickings again for the rest.
+
+ Inside the main workflow, some actions are accessible from the client:
+
+ * Change a lot or pack: if the expected lot is at the very bottom of the
+ location or a stock error forces a user to change lot or pack, user can
+ do it during the picking.
+ * Skip a line: during picking, for instance because a line is not accessible
+ easily, it can be postponed, note that skipped lines have to be done, they
+ are only moved to the end of the queue.
+ * Declare stock out: if a good is in fact not in stock or only partially. Note
+ the move lines will become unavailable or partially unavailable and will
+ generate a back-order.
+ * Full bin: declaring a full bin allows to move directly to the first phase
+ (picking) to the second one (unload). The process will go
+ back to the first phase if some lines remain in the queue of lines to pick.
+
+ Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
+ """
_inherit = "base.shopfloor.service"
_name = "shopfloor.cluster.picking"
@@ -14,72 +69,292 @@ def find_batch(self):
"""Find a picking batch to work on and start it
Usually the starting point of the process.
+
+ Business rules to find a batch, try in order:
+
+ a. Find an batch in progress assigned to the current user
+ b. Find a draft batch assigned to the current user:
+ 1. set it to 'in progress'
+ c. Find an unassigned draft batch:
+ 1. assign batch to the current user
+ 2. set it to 'in progress'
+
+ Transitions:
+ * confirm_start: when it could find a batch
+ * start: when no batch is available
"""
return self._response()
- def select(self, batch_id):
+ def select(self, picking_batch_id):
"""Manually select a picking batch
The client application can use the service /picking_batch/search
- to get the list of candidate batches.
+ to get the list of candidate batches. Then, it starts to work on
+ the selected batch by calling this.
+
+ Note: it should be able to work only on batches which are in draft or
+ (in progress and assigned to the current user), the search method that
+ lists batches filter them, but it has to be checked again here in case
+ of race condition.
+
+ Transitions:
+ * manual_selection: a selected batch cannot be used (assigned to someone else
+ concurrently for instance)
+ * confirm_start: after the batch has been assigned to the user
"""
return self._response()
- def unassign(self, batch_id):
- """Unassign and reset to draft a started picking batch"""
+ def confirm_start(self, picking_batch_id):
+ """User confirms they start a batch
+
+ Should have no effect in odoo besides logging and routing the user to
+ the next action. The next action is "start_line" with data about the
+ line to pick.
+
+ Transitions:
+ * start_line: when the batch has at least one line without destination
+ package
+ * start: if the condition above is wrong (rare case of race condition...)
+ """
+ return self._response()
+
+ def unassign(self, picking_batch_id):
+ """Unassign and reset to draft a started picking batch
+
+ Transitions:
+ * "start" to work on a new batch
+ """
return self._response(next_state="start")
def scan_line(self, move_line_id, barcode):
"""Scan a location, a pack, a product or a lots
- To validate the operator takes the expected pack or product.
+ There is no side-effect, it is only to check that the operator takes
+ the expected pack or product.
+
+ User can scan a location if there is only pack inside. Otherwise, they
+ have to precise what they want by scanning one of:
+
+ * pack
+ * product
+ * lot
+
+ The result must be unambigous. For instance if we scan a product but the
+ product is tracked by lot, scanning the lot has to be required.
+
+ Transitions:
+ * start_line: with an appropriate message when user has
+ to scan for the same line again
+ * start_line: with the next line if the line was added to a
+ pack meanwhile (race condition).
+ * scan_destination: if the barcode matches.
"""
return self._response()
- def scan_destination_pack(self, move_line_id, barcode):
- """Scan the destination pack (bin) for a move line
-
- It will change the destination of the move line
+ def scan_destination_pack(self, move_line_id, barcode, quantity):
+ """Scan the destination package (bin) for a move line
+
+ If the quantity picked (passed to the endpoint) is < expected quantity,
+ it splits the move line.
+ It changes the destination package of the move line and set the "qty done".
+ It prevents to put a move line of a picking in a destination package
+ used for another picking.
+
+ Transitions:
+ * zero_check: if the quantity of product moved is 0 in the
+ source location after the move (beware: at this point the product we put in
+ a bin is still considered to be in the source location, so we have to compute
+ the source location's quantity - qty_done).
+ * unload_all: when all lines have a destination package and they all
+ have the same destination.
+ * unload_single: when all lines have a destination package and they all
+ have the same destination.
+ * start_line: to pick the next line if any.
"""
return self._response()
def prepare_unload(self, picking_batch_id):
- """Scan the destination pack (bin) for a move line
+ """Initiate the unloading phase of the process
- It will change the destination of the move line
+ If the destination of all the move lines still to unload is the same,
+ it sets the flag ``cluster_picking_unload_all`` to True on
+ ``stock.batch.picking``.
+ Everytime this method is called, it resets the flag according to the
+ condition above.
+
+ Transitions:
+ * unload_all: when ``cluster_picking_unload_all`` is True
+ * unload_single: when ``cluster_picking_unload_all`` is False
"""
return self._response()
def is_zero(self, move_line_id, zero):
- """Confirm or not if the source location of a move has zero qty"""
+ """Confirm or not if the source location of a move has zero qty
+
+ If the user confirms there is zero quantity, it means the stock was
+ correct and there is nothing to do. If the user says "no", a draft
+ empty inventory is created for the product (with lot if tracked).
+
+ Transitions:
+ * start_line: if the batch has lines without destination package (bin)
+ * unload_all: if all lines have a destination package and same
+ destination
+ * unload_single: if all lines have a destination package and different
+ destination
+ """
return self._response()
def skip_line(self, move_line_id):
- """Skip a line. The line will be processed at the end."""
+ """Skip a line. The line will be processed at the end.
+
+ It adds a flag on the move line, when the next line to pick
+ is searched, lines with such flag at moved to the end.
+
+ A skipped line *must* be picked.
+
+ Transitions:
+ * start_line: with data for the next line (or itself if it's the last one,
+ in such case, a helpful message is returned)
+ """
return self._response()
def stock_issue(self, move_line_id):
- """Declare a stock issue for a line"""
+ """Declare a stock issue for a line
+
+ After errors in the stock, the user cannot take all the products
+ because there is physically not enough goods. The move line is
+ unassigned, and an inventory is created to reduce the quantity in the
+ source location to prevent future errors until a correction. Beware:
+ the quantity already reserved by other lines should remain reserved so
+ the inventory's quantity must be set to the quantity of lines reserved
+ by other move lines (but not the current one).
+
+ A second inventory is created in draft to have someone do an inventory.
+
+ Transitions:
+ * start_line: when the batch still contains lines without destination
+ package
+ * unload_all: if all lines have a destination package and same
+ destination
+ * unload_single: if all lines have a destination package and different
+ destination
+ * start: all lines are done/confirmed (because all lines were unloaded
+ and the last line has a stock issue). In this case, this method *has*
+ to handle the closing of the batch to create backorders. TODO find a
+ generic way to share actions happening on transitions such as "close
+ the batch"
+ """
return self._response()
def change_pack_lot(self, move_line_id, barcode):
- """Change the expected pack or the lot for a line"""
+ """Change the expected pack or the lot for a line
+
+ If the expected lot is at the very bottom of the location or a stock
+ error forces a user to change lot or pack, user can change the pack or
+ lot of the current line.
+
+ The change occurs when the pack/product/lot is normally scanned and
+ goes directly to the scan of the destination package (bin) since we do
+ not need to check it.
+
+ If the pack or lot was not supposed to be in the source location,
+ a draft inventory is created to have this checked.
+
+ Transitions:
+ * scan_destination: the pack or the lot could be changed
+ * start_line: any error occurred during the change
+ """
return self._response()
def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
- """Set the destination for all the lines of the batch"""
+ """Set the destination for all the lines with a dest. package of the batch
+
+ This method must be used only if all the move lines which have a destination
+ package and qty done have the same destination location.
+
+ A scanned location outside of the source location of the operation type is
+ invalid.
+
+ TODO: remove the destination package or not?
+
+ Transitions:
+ * start_line: the batch still have move lines without destination package
+ * unload_all: invalid destination, have to scan a good one
+ * confirm_unload_all: the scanned location is not the expected one (but
+ still a valid one)
+ * start: batch is totally done. In this case, this method *has*
+ to handle the closing of the batch to create backorders. TODO find a
+ generic way to share actions happening on transitions such as "close
+ the batch"
+ """
return self._response()
def unload_split(self, picking_batch_id, barcode, confirmation=False):
- """Set the destination for all the lines of the batch"""
+ """Indicates that now the batch must be treated line per line
+
+ Even if the move lines to unload all have the same destination.
+
+ It sets the flag ``stock_picking_batch.cluster_picking_unload_all`` to
+ False.
+
+ Note: if we go back to the first phase of picking and start a new
+ phase of unloading, the flag is reevaluated to the initial condition.
+
+ Transitions:
+ * unload_single: always goes here since we now want to unload line per line
+ """
+ return self._response()
+
+ def unload_router(self, picking_batch_id):
+ """Called after the info screen, route to the next state
+
+ No side effect in Odoo.
+
+ Transitions:
+ * unload_single: if the batch still has lines to unload
+ * start_line: if the batch still has lines to pick
+ * start: if the batch is done. In this case, this method *has*
+ to handle the closing of the batch to create backorders. TODO find a
+ generic way to share actions happening on transitions such as "close
+ the batch"
+ """
return self._response()
def unload_scan_pack(self, move_line_id, barcode):
- """Check that the operator scans the correct pack (bin)"""
+ """Check that the operator scans the correct package (bin) on unload
+
+ If the package is different as the destination package of the move line,
+ ask to scan it again.
+
+ Transitions:
+ * unload_single: if the barcode does not match
+ * unload_set_destination: barcode is correct
+ """
return self._response()
def unload_scan_destination(self, move_line_id, barcode, confirmation=False):
- """Check that the operator scans the correct pack (bin)"""
+ """Scan the final destination of the move line
+
+ TODO not sure. We have to call action_done on the picking *only when we
+ have scanned all the move lines* of the picking, so maybe we have to
+ keep track of this with a new flag on move lines?
+
+ Transitions:
+ * unload_single: invalid scanned location or error
+ * unload_single: line is processed and the next line can be unloaded
+ * confirm_unload_set_destination: the destination is valid but not the
+ expected, ask a confirmation. This state has to call again the
+ endpoint with confirmation=True
+ * show_completion_info: the completion info of the picking is
+ "next_picking_ready", it will show an info box to the user, the js
+ client should then call /unload_router to know the next state
+ * start_line: if the batch still has lines to pick
+ * start: if the batch is done. In this case, this method *has*
+ to handle the closing of the batch to create backorders. TODO find a
+ generic way to share actions happening on transitions such as "close
+ the batch"
+
+ """
return self._response()
@@ -94,10 +369,19 @@ def find_batch(self):
return {}
def select(self):
- return {"batch_id": {"coerce": to_int, "required": True, "type": "integer"}}
+ return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
+ }
+
+ def confirm_start(self):
+ return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
+ }
def unassign(self):
- return {"batch_id": {"coerce": to_int, "required": True, "type": "integer"}}
+ return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
+ }
def scan_line(self):
return {
@@ -109,6 +393,7 @@ def scan_destination_pack(self):
return {
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
"barcode": {"required": True, "type": "string"},
+ "quantity": {"coerce": to_float, "required": True, "type": "float"},
}
def prepare_unload(self):
@@ -146,6 +431,11 @@ def unload_split(self):
"picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
}
+ def unload_router(self):
+ return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
+ }
+
def unload_scan_pack(self):
return {
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
@@ -182,20 +472,31 @@ def _states(self):
"zero_check": self._schema_for_zero_check,
"unload_all": self._schema_for_unload_all,
"confirm_unload_all": self._schema_for_unload_all,
- "unload_confirm_pack": self._schema_for_unload_single,
+ "unload_single": self._schema_for_unload_single,
"unload_set_destination": self._schema_for_unload_single,
"confirm_unload_set_destination": self._schema_for_unload_single,
- "show_completion_info": self._schema_for_unload_single,
+ "show_completion_info": self._schema_for_completion_info,
}
def find_batch(self):
- return self._response_schema(
- next_states=["confirm_start", "start_line", "start"]
- )
+ return self._response_schema(next_states=["confirm_start", "start"])
def select(self):
return self._response_schema(next_states=["manual_selection", "confirm_start"])
+ def confirm_start(self):
+ return self._response_schema(
+ next_states=[
+ "start_line",
+ # "start" should be pretty rare, only if the batch has been
+ # canceled, deleted meanwhile...
+ # TODO every state could bring back to 'start' in case of
+ # unrecoverable error, maybe we should add an attribute
+ # `_start_state = "start"` and implicitly add it in states
+ "start",
+ ]
+ )
+
def unassign(self):
return self._response_schema(next_states=["start"])
@@ -214,7 +515,7 @@ def scan_destination_pack(self):
"unload_all",
# when all lines have been processed and have different
# destinations
- "unload_confirm_pack",
+ "unload_single",
]
)
@@ -226,7 +527,7 @@ def prepare_unload(self):
"unload_all",
# when all lines have been processed and have different
# destinations
- "unload_confirm_pack",
+ "unload_single",
]
)
@@ -240,7 +541,7 @@ def is_zero(self):
"unload_all",
# when all lines have been processed and have different
# destinations
- "unload_confirm_pack",
+ "unload_single",
]
)
@@ -257,18 +558,21 @@ def stock_issue(self):
"unload_all",
# when all lines have been processed and have different
# destinations
- "unload_confirm_pack",
+ "unload_single",
+ "start",
]
)
def change_pack_lot(self):
- return self._response_schema(next_states=["scan_destination"])
+ return self._response_schema(next_states=["scan_destination", "start_line"])
def set_destination_all(self):
return self._response_schema(
next_states=[
# if the batch still contain lines
"start_line",
+ # invalid destination, have to scan a valid one
+ "unload_all",
# different destination to confirm
"confirm_unload_all",
# batch finished
@@ -277,17 +581,22 @@ def set_destination_all(self):
)
def unload_split(self):
- return self._response_schema(next_states=["unload_confirm_pack"])
+ return self._response_schema(next_states=["unload_single"])
+
+ def unload_router(self):
+ return self._response_schema(
+ next_states=["unload_single", "start_line", "start"]
+ )
def unload_scan_pack(self):
return self._response_schema(
- next_states=["unload_confirm_pack", "unload_set_destination"]
+ next_states=["unload_single", "unload_set_destination"]
)
def unload_scan_destination(self):
return self._response_schema(
next_states=[
- "unload_confirm_pack",
+ "unload_single",
"confirm_unload_set_destination",
"show_completion_info",
"start",
@@ -295,9 +604,11 @@ def unload_scan_destination(self):
]
)
+ # TODO single class for sharing schemas between services
@property
def _schema_for_batch_details(self):
return {
+ # TODO full name instead of id? or always wrap in batch/move_line?
# id is a stock.picking.batch
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
@@ -442,3 +753,12 @@ def _schema_for_zero_check(self):
},
},
}
+
+ @property
+ def _schema_for_completion_info(self):
+ return {
+ # stock.picking.batch
+ "id": {"required": True, "type": "integer"},
+ "picking_done": {"type": "string", "nullable": False, "required": True},
+ "picking_next": {"type": "string", "nullable": False, "required": True},
+ }
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 9290fecdb7..e34e6c45d5 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -6,6 +6,14 @@
from odoo.addons.component.core import AbstractComponent, WorkContext
+def to_float(val):
+ if isinstance(val, float):
+ return val
+ if val:
+ return float(val)
+ return None
+
+
class BaseShopfloorService(AbstractComponent):
"""Base class for REST services"""
From 614e6ac5d95f32e0cf63a59ccd60a7c3c881e60d Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 17 Feb 2020 08:42:57 +0100
Subject: [PATCH 087/940] Change _search methods return records instead of json
So they can be used internally while public search return json
for REST.
---
shopfloor/services/menu.py | 8 +++++---
shopfloor/services/picking_batch.py | 9 +++++----
shopfloor/services/profile.py | 6 +++---
3 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index fd2a015b86..140c3bfde3 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -38,12 +38,14 @@ def _search(self, name_fragment=None):
if name_fragment:
domain.append(("name", "ilike", name_fragment))
records = self.env[self._expose_model].search(domain)
- return self._to_json(records)
+ return records
def search(self, name_fragment=None):
"""List available menu entries for current user"""
- json_records = self._search(name_fragment=name_fragment)
- return self._response(data={"size": len(json_records), "records": json_records})
+ records = self._search(name_fragment=name_fragment)
+ return self._response(
+ data={"size": len(records), "records": self._to_json(records)}
+ )
def _convert_one_record(self, record):
return {
diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py
index a4d2cfe2d8..3646ce850c 100644
--- a/shopfloor/services/picking_batch.py
+++ b/shopfloor/services/picking_batch.py
@@ -45,16 +45,17 @@ def _search(self, name_fragment=None):
for picking in batch.picking_ids
)
)
- # TODO why already convert to json in the internal method?
- return self._to_json(records)
+ return records
def search(self, name_fragment=None):
"""List available stock picking batches for current user
Show only picking batches where all the pickings are available.
"""
- json_records = self._search(name_fragment=name_fragment)
- return self._response(data={"size": len(json_records), "records": json_records})
+ records = self._search(name_fragment=name_fragment)
+ return self._response(
+ data={"size": len(records), "records": self._to_json(records)}
+ )
def _convert_one_record(self, record):
assigned_pickings = record.picking_ids.filtered(
diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py
index 395971a73a..94ee11d5ed 100644
--- a/shopfloor/services/profile.py
+++ b/shopfloor/services/profile.py
@@ -30,13 +30,13 @@ def _search(self, name_fragment=None):
if name_fragment:
domain.append(("name", "ilike", name_fragment))
records = self.env[self._expose_model].search(domain)
- return self._to_json(records)
+ return records
def search(self, name_fragment=None):
"""List available profiles for current user"""
- json_records = self._search(name_fragment=name_fragment)
+ records = self._search(name_fragment=name_fragment)
return self._response(
- data={"size": len(json_records), "records": self._to_json(json_records)}
+ data={"size": len(records), "records": self._to_json(records)}
)
def _get_base_search_domain(self):
From 820db541d834cff5d3e9cd360a3c783fdeda15e4 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 17 Feb 2020 09:46:21 +0100
Subject: [PATCH 088/940] cluster_picking: implement /find_batch
---
shopfloor/services/cluster_picking.py | 62 +++++++++-
shopfloor/services/picking_batch.py | 14 ++-
shopfloor/tests/common.py | 3 +-
shopfloor/tests/test_cluster_picking.py | 143 ++++++++++++++++++++++--
4 files changed, 209 insertions(+), 13 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 9165382afb..d6bb3915e9 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1,3 +1,5 @@
+from odoo import _
+
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
@@ -72,7 +74,7 @@ def find_batch(self):
Business rules to find a batch, try in order:
- a. Find an batch in progress assigned to the current user
+ a. Find a batch in progress assigned to the current user
b. Find a draft batch assigned to the current user:
1. set it to 'in progress'
c. Find an unassigned draft batch:
@@ -83,7 +85,62 @@ def find_batch(self):
* confirm_start: when it could find a batch
* start: when no batch is available
"""
- return self._response()
+ batch_service = self.component(usage="picking_batch")
+ batches = batch_service._search()
+ # look for in progress + assigned to self first
+ candidates = batches.filtered(
+ lambda batch: batch.state == "in_progress"
+ and batch.user_id == self.env.user
+ )
+ if candidates:
+ return self._response_for_confirm_start(candidates[0])
+ # then look for draft assigned to self
+ candidates = batches.filtered(lambda batch: batch.user_id == self.env.user)
+ if candidates:
+ batch = candidates[0]
+ batch.write({"state": "in_progress"})
+ return self._response_for_confirm_start(batch)
+ # finally take any batch that search could return
+ if batches:
+ batch = batches[0]
+ batch.write({"user_id": self.env.uid, "state": "in_progress"})
+ return self._response_for_confirm_start(batch)
+ return self._response_for_no_batch_found()
+
+ def _response_for_no_batch_found(self):
+ return self._response(
+ next_state="start",
+ message={
+ "message_type": "info",
+ "message": _("No more work to do, please create a new batch transfer"),
+ },
+ )
+
+ def _response_for_confirm_start(self, batch):
+ pickings = []
+ for picking in batch.picking_ids:
+ p_values = {
+ "id": picking.id,
+ "name": picking.name,
+ "move_line_count": len(picking.move_line_ids),
+ "origin": picking.origin or "",
+ }
+ if picking.partner_id:
+ p_values["partner"] = {
+ "id": picking.partner_id.id,
+ "name": picking.partner_id.name,
+ }
+ pickings.append(p_values)
+ return self._response(
+ next_state="confirm_start",
+ data={
+ "id": batch.id,
+ "name": batch.name,
+ # TODO
+ "weight": 0,
+ "pickings": pickings,
+ },
+ )
def select(self, picking_batch_id):
"""Manually select a picking batch
@@ -629,6 +686,7 @@ def _schema_for_batch_details(self):
},
"partner": {
"type": "dict",
+ "required": False,
"schema": {
"id": {"required": True, "type": "integer"},
"name": {
diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py
index 3646ce850c..e6d22c82c7 100644
--- a/shopfloor/services/picking_batch.py
+++ b/shopfloor/services/picking_batch.py
@@ -41,7 +41,16 @@ def _search(self, name_fragment=None):
# batch even if some pickings are done/canceled. They'll should be
# ignored later.
lambda batch: all(
- picking.state in ("assigned", "done", "cancel")
+ (
+ # When the batch is already in progress, we do not care
+ # about state of the pickings, because we want to be able
+ # to recover it in any case, even if, for instance, a stock
+ # error changed a picking to unavailable after the user
+ # started to work on the batch.
+ batch.state == "in_progress"
+ or picking.state in ("assigned", "done", "cancel")
+ )
+ and picking.picking_type_id in self.picking_types
for picking in batch.picking_ids
)
)
@@ -50,7 +59,8 @@ def _search(self, name_fragment=None):
def search(self, name_fragment=None):
"""List available stock picking batches for current user
- Show only picking batches where all the pickings are available.
+ Show only picking batches where all the pickings are available and
+ where all pickings are in the picking type of the current process.
"""
records = self._search(name_fragment=name_fragment)
return self._response(
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 2c7aed84fa..386d0c4956 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -61,6 +61,7 @@ def setUpClassVars(cls):
cls.input_location = cls.env.ref("stock.stock_location_company")
cls.shelf1 = cls.env.ref("stock.stock_location_components")
cls.shelf2 = cls.env.ref("stock.stock_location_14")
+ cls.customer = cls.env["res.partner"].create({"name": "Customer"})
def assert_response(self, response, next_state=None, message=None, data=None):
"""Assert a response from the webservice
@@ -133,7 +134,7 @@ def _update_qty_in_location(self, location, product, quantity):
def _fill_stock_for_pickings(self, pickings):
product_locations = {}
- for move in self.all_batches.mapped("picking_ids.move_lines"):
+ for move in pickings.mapped("move_lines"):
key = (move.product_id, move.location_id)
product_locations.setdefault(key, 0)
product_locations[key] += move.product_qty
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 7fd1d38430..f022cf7faf 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -18,7 +18,9 @@ def setUpClass(cls, *args, **kwargs):
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
cls.picking_type = cls.process.picking_type_ids
- cls.main_batch = cls._create_batch_picking(cls.product_a)
+ cls.batch1 = cls._create_picking_batch(cls.product_a)
+ cls.batch2 = cls._create_picking_batch(cls.product_a)
+ cls.batch3 = cls._create_picking_batch(cls.product_a)
def setUp(self):
super().setUp()
@@ -26,11 +28,13 @@ def setUp(self):
self.service = work.component(usage="cluster_picking")
@classmethod
- def _create_batch_picking(cls, product):
+ def _create_picking_batch(cls, product):
picking_form = Form(cls.env["stock.picking"])
picking_form.picking_type_id = cls.picking_type
picking_form.location_id = cls.stock_location
picking_form.location_dest_id = cls.packing_location
+ picking_form.origin = "test {}".format(product.name)
+ picking_form.partner_id = cls.customer
with picking_form.move_ids_without_package.new() as move:
move.product_id = product
move.product_uom_qty = 1
@@ -46,9 +50,132 @@ def test_to_openapi(self):
# will raise if it fails to generate the openapi specs
self.service.to_openapi()
- # def test_list_manual_batch(self):
- # """Test getting list of picking batches the user can work on"""
- # # Simulate the client asking the list of picking batch it can
- # # select after the user clicked on the "Manual Selection" button
- # response = self.service.dispatch("/list_manual_selection")
- # import pdb; pdb.set_trace()
+ def _add_stock_and_assign_pickings_for_batches(self, batches):
+ pickings = batches.mapped("picking_ids")
+ self._fill_stock_for_pickings(pickings)
+ pickings.action_assign()
+
+ def test_find_batch_in_progress_current_user(self):
+ """Find an in-progress batch assigned to the current user"""
+ # Simulate the client asking a batch by clicking on "get work"
+ self._add_stock_and_assign_pickings_for_batches(
+ self.batch1 | self.batch2 | self.batch3
+ )
+ self.batch3.user_id = self.env.uid
+ self.batch3.confirm_picking() # set to in progress
+ response = self.service.dispatch("find_batch")
+
+ # we expect to find batch 3 as it's assigned to the current
+ # user and in progress (first priority)
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch3.id,
+ "name": self.batch3.name,
+ # TODO
+ "weight": 0,
+ "pickings": [
+ {
+ "id": self.batch3.picking_ids.id,
+ "name": self.batch3.picking_ids.name,
+ "move_line_count": len(self.batch3.picking_ids.move_line_ids),
+ "origin": self.batch3.picking_ids.origin,
+ "partner": {
+ "id": self.batch3.picking_ids.partner_id.id,
+ "name": self.batch3.picking_ids.partner_id.name,
+ },
+ }
+ ],
+ },
+ )
+
+ def test_find_batch_assigned(self):
+ """Find a draft batch assigned to the current user"""
+ # batches must have all their pickings available to be selected
+ self._add_stock_and_assign_pickings_for_batches(
+ self.batch1 | self.batch2 | self.batch3
+ )
+ # batch2 in draft but assigned to the current user should be
+ # selected before the others
+ self.batch2.user_id = self.env.uid
+ response = self.service.dispatch("find_batch")
+
+ # The endpoint starts the batch
+ self.assertEqual(self.batch2.state, "in_progress")
+
+ # we expect to find batch 2 as it's assigned to the current user
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch2.id,
+ "name": self.batch2.name,
+ # TODO
+ "weight": 0,
+ "pickings": [
+ {
+ "id": self.batch2.picking_ids.id,
+ "name": self.batch2.picking_ids.name,
+ "move_line_count": len(self.batch2.picking_ids.move_line_ids),
+ "origin": self.batch2.picking_ids.origin,
+ "partner": {
+ "id": self.batch2.picking_ids.partner_id.id,
+ "name": self.batch2.picking_ids.partner_id.name,
+ },
+ }
+ ],
+ },
+ )
+
+ def test_find_batch_unassigned_draft(self):
+ """Find a draft batch"""
+ # batches must have all their pickings available to be selected
+ self._add_stock_and_assign_pickings_for_batches(self.batch2 | self.batch3)
+ # batch1 has not all pickings available, so the first draft
+ # is batch2, should be selected
+ response = self.service.dispatch("find_batch")
+
+ # The endpoint starts the batch and assign it to self
+ self.assertEqual(self.batch2.user_id, self.env.user)
+ self.assertEqual(self.batch2.state, "in_progress")
+
+ # we expect to find batch 2 as it's the first one with all pickings
+ # available
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch2.id,
+ "name": self.batch2.name,
+ # TODO
+ "weight": 0,
+ "pickings": [
+ {
+ "id": self.batch2.picking_ids.id,
+ "name": self.batch2.picking_ids.name,
+ "move_line_count": len(self.batch2.picking_ids.move_line_ids),
+ "origin": self.batch2.picking_ids.origin,
+ "partner": {
+ "id": self.batch2.picking_ids.partner_id.id,
+ "name": self.batch2.picking_ids.partner_id.name,
+ },
+ }
+ ],
+ },
+ )
+
+ def test_find_batch_not_found(self):
+ """No batch to work on"""
+ # No batch match the rules to work on them, because
+ # their pickings are not available
+ response = self.service.dispatch("find_batch")
+
+ self.assert_response(
+ response,
+ next_state="start",
+ message={
+ "message_type": "info",
+ "message": "No more work to do, please create a new batch transfer",
+ },
+ )
From edfffedee4619bd3ce0746108987b424c8025503 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 17 Feb 2020 11:55:05 +0100
Subject: [PATCH 089/940] cluster_picking: implement /select
---
shopfloor/services/cluster_picking.py | 33 ++++++--
shopfloor/services/picking_batch.py | 6 +-
shopfloor/tests/test_cluster_picking.py | 104 ++++++++++++++++++++++++
3 files changed, 136 insertions(+), 7 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index d6bb3915e9..4854799dce 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -87,25 +87,33 @@ def find_batch(self):
"""
batch_service = self.component(usage="picking_batch")
batches = batch_service._search()
+ selected = self._select_a_picking_batch(batches)
+ if selected:
+ return self._response_for_confirm_start(selected)
+ else:
+ return self._response_for_no_batch_found()
+
+ # TODO this may be used in other scenarios? if so, extract
+ def _select_a_picking_batch(self, batches):
# look for in progress + assigned to self first
candidates = batches.filtered(
lambda batch: batch.state == "in_progress"
and batch.user_id == self.env.user
)
if candidates:
- return self._response_for_confirm_start(candidates[0])
+ return candidates[0]
# then look for draft assigned to self
candidates = batches.filtered(lambda batch: batch.user_id == self.env.user)
if candidates:
batch = candidates[0]
batch.write({"state": "in_progress"})
- return self._response_for_confirm_start(batch)
+ return batch
# finally take any batch that search could return
if batches:
batch = batches[0]
batch.write({"user_id": self.env.uid, "state": "in_progress"})
- return self._response_for_confirm_start(batch)
- return self._response_for_no_batch_found()
+ return batch
+ return self.env["stock.picking.batch"]
def _response_for_no_batch_found(self):
return self._response(
@@ -142,6 +150,15 @@ def _response_for_confirm_start(self, batch):
},
)
+ def _response_for_batch_cannot_be_selected(self):
+ return self._response(
+ next_state="manual_selection",
+ message={
+ "message_type": "warning",
+ "message": _("This batch cannot be selected."),
+ },
+ )
+
def select(self, picking_batch_id):
"""Manually select a picking batch
@@ -159,7 +176,13 @@ def select(self, picking_batch_id):
concurrently for instance)
* confirm_start: after the batch has been assigned to the user
"""
- return self._response()
+ batch_service = self.component(usage="picking_batch")
+ batch = batch_service._search(batch_ids=[picking_batch_id])
+ selected = self._select_a_picking_batch(batch)
+ if selected:
+ return self._response_for_confirm_start(selected)
+ else:
+ return self._response_for_batch_cannot_be_selected()
def confirm_start(self, picking_batch_id):
"""User confirms they start a batch
diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py
index e6d22c82c7..90716d83c0 100644
--- a/shopfloor/services/picking_batch.py
+++ b/shopfloor/services/picking_batch.py
@@ -31,10 +31,12 @@ def _get_base_search_domain(self):
]
)
- def _search(self, name_fragment=None):
+ def _search(self, name_fragment=None, batch_ids=None):
domain = self._get_base_search_domain()
if name_fragment:
- domain.append(("name", "ilike", name_fragment))
+ domain = expression.AND([domain, [("name", "ilike", name_fragment)]])
+ if batch_ids:
+ domain = expression.AND([domain, [("id", "in", batch_ids)]])
records = self.env[self._expose_model].search(domain, order="id asc")
records = records.filtered(
# Include done/cancel because we want to be able to work on the
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index f022cf7faf..64e4c26417 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -179,3 +179,107 @@ def test_find_batch_not_found(self):
"message": "No more work to do, please create a new batch transfer",
},
)
+
+ def test_select_in_progress_assigned(self):
+ """Select an in-progress batch assigned to the current user"""
+ self._add_stock_and_assign_pickings_for_batches(self.batch1)
+ self.batch1.write({"state": "in_progress", "user_id": self.env.uid})
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "select", params={"picking_batch_id": self.batch1.id}
+ )
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch1.id,
+ "name": self.batch1.name,
+ # we don't care in these tests, the 'find_batch' tests already
+ # check this
+ "weight": self.ANY,
+ "pickings": self.ANY,
+ },
+ )
+
+ def test_select_draft_assigned(self):
+ """Select a draft batch assigned to the current user"""
+ self._add_stock_and_assign_pickings_for_batches(self.batch1)
+ self.batch1.write({"user_id": self.env.uid})
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "select", params={"picking_batch_id": self.batch1.id}
+ )
+ # The endpoint starts the batch and assign it to self
+ self.assertEqual(self.batch1.user_id, self.env.user)
+ self.assertEqual(self.batch1.state, "in_progress")
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch1.id,
+ "name": self.batch1.name,
+ # we don't care in these tests, the 'find_batch' tests already
+ # check this
+ "weight": self.ANY,
+ "pickings": self.ANY,
+ },
+ )
+
+ def test_select_draft_unassigned(self):
+ """Select a draft batch not assigned to a user"""
+ self._add_stock_and_assign_pickings_for_batches(self.batch1)
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "select", params={"picking_batch_id": self.batch1.id}
+ )
+ # The endpoint starts the batch and assign it to self
+ self.assertEqual(self.batch1.user_id, self.env.user)
+ self.assertEqual(self.batch1.state, "in_progress")
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch1.id,
+ "name": self.batch1.name,
+ # we don't care in these tests, the 'find_batch' tests already
+ # check this
+ "weight": self.ANY,
+ "pickings": self.ANY,
+ },
+ )
+
+ def test_select_not_exists(self):
+ """Select a draft that does not exist"""
+ batch_id = self.batch1.id
+ self.batch1.unlink()
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "select", params={"picking_batch_id": batch_id}
+ )
+ self.assert_response(
+ response,
+ next_state="manual_selection",
+ message={
+ "message_type": "warning",
+ "message": "This batch cannot be selected.",
+ },
+ )
+
+ def test_select_already_assigned(self):
+ """Select a draft that does not exist"""
+ self._add_stock_and_assign_pickings_for_batches(self.batch1)
+ self.batch1.write(
+ {"state": "in_progress", "user_id": self.env.ref("base.user_demo")}
+ )
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "select", params={"picking_batch_id": self.batch1.id}
+ )
+ self.assert_response(
+ response,
+ next_state="manual_selection",
+ message={
+ "message_type": "warning",
+ "message": "This batch cannot be selected.",
+ },
+ )
From 04aae0fddbc8abb08f4db0f137ad5df2cbb5e727 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 17 Feb 2020 12:18:02 +0100
Subject: [PATCH 090/940] cluster_picking: implement /unassign
---
shopfloor/services/cluster_picking.py | 3 +++
shopfloor/tests/test_cluster_picking.py | 22 ++++++++++++++++++++++
2 files changed, 25 insertions(+)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 4854799dce..7b6b5e84bf 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -204,6 +204,9 @@ def unassign(self, picking_batch_id):
Transitions:
* "start" to work on a new batch
"""
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if batch.exists():
+ batch.write({"state": "draft", "user_id": False})
return self._response(next_state="start")
def scan_line(self, move_line_id, barcode):
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 64e4c26417..0be61f2fcd 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -283,3 +283,25 @@ def test_select_already_assigned(self):
"message": "This batch cannot be selected.",
},
)
+
+ def test_unassign_batch(self):
+ """User cancels after selecting a batch, unassign it"""
+ self._add_stock_and_assign_pickings_for_batches(self.batch1)
+ self.batch1.write({"state": "in_progress", "user_id": self.env.uid})
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "unassign", params={"picking_batch_id": self.batch1.id}
+ )
+ self.assertEqual(self.batch1.state, "draft")
+ self.assertFalse(self.batch1.user_id)
+ self.assert_response(response, next_state="start")
+
+ def test_unassign_batch_not_exists(self):
+ """User cancels after selecting a batch deleted meanwhile"""
+ batch_id = self.batch1.id
+ self.batch1.unlink()
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "unassign", params={"picking_batch_id": batch_id}
+ )
+ self.assert_response(response, next_state="start")
From d56b6469aa590844e6a30a891da2a871334e2c04 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 17 Feb 2020 15:46:50 +0100
Subject: [PATCH 091/940] cluster_picking: update docstrings
---
shopfloor/services/cluster_picking.py | 35 +++++++++++++++------------
1 file changed, 19 insertions(+), 16 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 7b6b5e84bf..7ec38bcfef 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -7,7 +7,8 @@
class ClusterPicking(Component):
- """Methods for the Cluster Picking Process
+ """
+ Methods for the Cluster Picking Process
The goal of this process is to do the pickings for a Picking Batch, for
several customers at once.
@@ -37,10 +38,11 @@ class ClusterPicking(Component):
* If all the goods (move lines) in the roller-cage go to the same destination,
a screen asking a single barcode for the destination is shown
- * Otherwise, the user has to scan one destination per good.
+ * Otherwise, the user has to scan one destination per Bin (destination
+ package of the moves).
* If all the goods are supposed to go to the same destination but user doesn't
want or can't, a "split" allows to reach the screen to scan one destination
- per good.
+ per Bin.
* When everything has a destination set and the batch is not finished yet,
the user goes to the first phase of pickings again for the rest.
@@ -350,7 +352,7 @@ def change_pack_lot(self, move_line_id, barcode):
return self._response()
def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
- """Set the destination for all the lines with a dest. package of the batch
+ """Set the destination for all the lines of the batch with a dest. package
This method must be used only if all the move lines which have a destination
package and qty done have the same destination location.
@@ -358,8 +360,6 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
A scanned location outside of the source location of the operation type is
invalid.
- TODO: remove the destination package or not?
-
Transitions:
* start_line: the batch still have move lines without destination package
* unload_all: invalid destination, have to scan a good one
@@ -394,7 +394,7 @@ def unload_router(self, picking_batch_id):
No side effect in Odoo.
Transitions:
- * unload_single: if the batch still has lines to unload
+ * unload_single: if the batch still has packs to unload
* start_line: if the batch still has lines to pick
* start: if the batch is done. In this case, this method *has*
to handle the closing of the batch to create backorders. TODO find a
@@ -403,11 +403,11 @@ def unload_router(self, picking_batch_id):
"""
return self._response()
- def unload_scan_pack(self, move_line_id, barcode):
+ def unload_scan_pack(self, package_id, barcode):
"""Check that the operator scans the correct package (bin) on unload
- If the package is different as the destination package of the move line,
- ask to scan it again.
+ If the scanned barcode is not the one of the Bin (package), ask to scan
+ again.
Transitions:
* unload_single: if the barcode does not match
@@ -415,16 +415,19 @@ def unload_scan_pack(self, move_line_id, barcode):
"""
return self._response()
- def unload_scan_destination(self, move_line_id, barcode, confirmation=False):
- """Scan the final destination of the move line
+ def unload_scan_destination(self, package_id, barcode, confirmation=False):
+ """Scan the final destination for all the move lines moved with the Bin
- TODO not sure. We have to call action_done on the picking *only when we
- have scanned all the move lines* of the picking, so maybe we have to
+ It updates all the assigned move lines with the package to the
+ destination.
+
+ TODO not sure: We have to call action_done on the picking *only when we
+ have scanned all the packages* of the picking, so maybe we have to
keep track of this with a new flag on move lines?
Transitions:
* unload_single: invalid scanned location or error
- * unload_single: line is processed and the next line can be unloaded
+ * unload_single: line is processed and the next bin can be unloaded
* confirm_unload_set_destination: the destination is valid but not the
expected, ask a confirmation. This state has to call again the
endpoint with confirmation=True
@@ -521,7 +524,7 @@ def unload_router(self):
def unload_scan_pack(self):
return {
- "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
"barcode": {"required": True, "type": "string"},
}
From 82a70e24d96bec0893cf5c4f359ee085fda44a28 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 17 Feb 2020 15:50:23 +0100
Subject: [PATCH 092/940] backend: fix app endpoints
---
shopfloor/services/app.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py
index aa70424129..80bfbfd444 100644
--- a/shopfloor/services/app.py
+++ b/shopfloor/services/app.py
@@ -34,8 +34,8 @@ class ShopfloorAppValidatorResponse(Component):
_usage = "app.validator.response"
def user_config(self):
- menu_service = self.component("menu")
- profile_service = self.component("profile")
+ menu_return_validator = self.component("menu.validator.response")
+ profile_return_validator = self.component("profile.validator.response")
return self._response_schema(
{
"menus": {
@@ -43,7 +43,7 @@ def user_config(self):
"required": True,
"schema": {
"type": "dict",
- "schema": menu_service._record_return_schema,
+ "schema": menu_return_validator._record_schema,
},
},
"profiles": {
@@ -51,7 +51,7 @@ def user_config(self):
"required": True,
"schema": {
"type": "dict",
- "schema": profile_service._record_return_schema,
+ "schema": profile_return_validator._record_schema,
},
},
}
From fb7050b5a051f94864b58d4d1fabc0a32ad74437 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 17 Feb 2020 16:12:45 +0100
Subject: [PATCH 093/940] backend: Inverse cardinality between process and
picking type
In the workflows, we need to know which picking type we are working
on, so we can't have more than one picking type.
Several processes and menus can be created when a scenario can be needed
on several picking types.
---
shopfloor/__manifest__.py | 2 +-
shopfloor/demo/shopfloor_process_demo.xml | 3 +++
shopfloor/demo/stock_picking_type_demo.xml | 3 ---
shopfloor/models/shopfloor_process.py | 4 ++--
shopfloor/models/stock_picking_type.py | 7 ++++++-
shopfloor/security/ir.model.access.csv | 1 +
shopfloor/services/picking_batch.py | 2 +-
shopfloor/services/service.py | 16 +++++++++++-----
shopfloor/services/single_pack_putaway.py | 2 +-
shopfloor/services/single_pack_transfer.py | 4 ++--
shopfloor/tests/test_cluster_picking.py | 2 +-
shopfloor/tests/test_picking_batch.py | 2 +-
shopfloor/tests/test_single_pack_putaway.py | 4 ++--
shopfloor/tests/test_single_pack_transfer.py | 2 +-
shopfloor/views/shopfloor_process.xml | 5 +++--
shopfloor/views/stock_picking_type.xml | 2 +-
16 files changed, 37 insertions(+), 24 deletions(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index d2dbcaa11f..b48d72b819 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -30,8 +30,8 @@
],
"demo": [
"demo/auth_api_key_demo.xml",
- "demo/shopfloor_process_demo.xml",
"demo/stock_picking_type_demo.xml",
+ "demo/shopfloor_process_demo.xml",
"demo/shopfloor_menu_demo.xml",
"demo/shopfloor_operation_group_demo.xml",
"demo/shopfloor_profile_demo.xml",
diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml
index f2aab9b212..660079c145 100644
--- a/shopfloor/demo/shopfloor_process_demo.xml
+++ b/shopfloor/demo/shopfloor_process_demo.xml
@@ -3,16 +3,19 @@
Put-Away Reach Truck
single_pack_putaway
+
Single Pallet Transfer
single_pack_transfer
+
Cluster Picking
cluster_picking
+
diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml
index 80792ca479..653cac7759 100644
--- a/shopfloor/demo/stock_picking_type_demo.xml
+++ b/shopfloor/demo/stock_picking_type_demo.xml
@@ -13,7 +13,6 @@
internal
-
@@ -30,7 +29,6 @@
internal
-
@@ -47,7 +45,6 @@
internal
-
diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py
index 24890d6a59..bff78f93e3 100644
--- a/shopfloor/models/shopfloor_process.py
+++ b/shopfloor/models/shopfloor_process.py
@@ -7,8 +7,8 @@ class ShopfloorProcess(models.Model):
name = fields.Char(required=True)
code = fields.Selection(selection="_selection_code", required=True)
- picking_type_ids = fields.One2many(
- "stock.picking.type", "process_id", string="Operation types"
+ picking_type_id = fields.Many2one(
+ comodel_name="stock.picking.type", string="Operation Type"
)
menu_ids = fields.One2many(comodel_name="shopfloor.menu", inverse_name="process_id")
diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py
index 808bf50552..80374d89b9 100644
--- a/shopfloor/models/stock_picking_type.py
+++ b/shopfloor/models/stock_picking_type.py
@@ -4,4 +4,9 @@
class StockPickingType(models.Model):
_inherit = "stock.picking.type"
- process_id = fields.Many2one("shopfloor.process", string="Process")
+ process_ids = fields.One2many(
+ comodel_name="shopfloor.process",
+ inverse_name="picking_type_id",
+ string="Shopfloor Processes",
+ readonly=True,
+ )
diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv
index a616c579ee..41e5dd07c5 100644
--- a/shopfloor/security/ir.model.access.csv
+++ b/shopfloor/security/ir.model.access.csv
@@ -6,3 +6,4 @@
"access_shopfloor_profile_users","shopfloor profile","model_shopfloor_profile",,1,0,0,0
"access_shopfloor_profile_stock_manager","shopfloor profile inventory manager","model_shopfloor_profile","stock.group_stock_manager",1,1,1,1
"access_shopfloor_process_users","shopfloor process","model_shopfloor_process",,1,0,0,0
+"access_shopfloor_process_stock_manager","shopfloor process inventory manager","model_shopfloor_process","stock.group_stock_manager",1,1,1,1
diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py
index 90716d83c0..83cb0b2d71 100644
--- a/shopfloor/services/picking_batch.py
+++ b/shopfloor/services/picking_batch.py
@@ -52,7 +52,7 @@ def _search(self, name_fragment=None, batch_ids=None):
batch.state == "in_progress"
or picking.state in ("assigned", "done", "cancel")
)
- and picking.picking_type_id in self.picking_types
+ and picking.picking_type_id == self.picking_type
for picking in batch.picking_ids
)
)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index e34e6c45d5..cd2638fc2e 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -1,4 +1,4 @@
-from odoo import _, fields
+from odoo import _, exceptions, fields
from odoo.exceptions import MissingError
from odoo.osv import expression
@@ -24,13 +24,19 @@ class BaseShopfloorService(AbstractComponent):
_expose_model = None
@property
- def picking_types(self):
+ def picking_type(self):
"""
Get the current picking type based on the menu and the warehouse of the profile.
"""
- return self.work.menu.process_id.picking_type_ids.filtered(
- lambda p: p.warehouse_id == self.work.profile.warehouse_id
- )
+ picking_type = self.work.menu.process_id.picking_type_id
+ if picking_type.warehouse_id != self.work.profile.warehouse_id:
+ raise exceptions.UserError(
+ _("Process {} cannot be used on warehouse {}").format(
+ picking_type.display_name,
+ self.work.profile.warehouse_id.display_name,
+ )
+ )
+ return picking_type
def _get(self, _id):
domain = expression.normalize_domain(self._get_base_search_domain())
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index b365f644b4..a4239c0880 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -86,7 +86,7 @@ def _response_for_start_success(self, move_line, pack):
def start(self, barcode):
"""Scan a pack barcode"""
- picking_type = self.picking_types
+ picking_type = self.picking_type
if len(picking_type) > 1:
return self._response_for_several_picking_types()
elif not picking_type:
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 4cb233bc69..550ee27ec8 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -84,7 +84,7 @@ def _response_for_start_success(self, move_line, pack):
def start(self, barcode):
search = self.actions_for("search")
- picking_type = self.picking_types
+ picking_type = self.picking_type
if len(picking_type) > 1:
return self._response_for_several_picking_types()
@@ -113,7 +113,7 @@ def start(self, barcode):
[
("package_id", "=", pack.id),
("state", "!=", "done"),
- ("picking_id.picking_type_id", "in", self.picking_types.ids),
+ ("picking_id.picking_type_id", "=", self.picking_type.id),
]
)
if not existing_operations:
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 0be61f2fcd..7b12480303 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -17,7 +17,7 @@ def setUpClass(cls, *args, **kwargs):
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
- cls.picking_type = cls.process.picking_type_ids
+ cls.picking_type = cls.process.picking_type_id
cls.batch1 = cls._create_picking_batch(cls.product_a)
cls.batch2 = cls._create_picking_batch(cls.product_a)
cls.batch3 = cls._create_picking_batch(cls.product_a)
diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py
index 374261de59..c11bee3a06 100644
--- a/shopfloor/tests/test_picking_batch.py
+++ b/shopfloor/tests/test_picking_batch.py
@@ -18,7 +18,7 @@ def setUpClass(cls, *args, **kwargs):
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
- cls.picking_type = cls.process.picking_type_ids
+ cls.picking_type = cls.process.picking_type_id
cls.batch1 = cls._create_picking_batch(cls.product_a)
cls.batch2 = cls._create_picking_batch(cls.product_a)
cls.batch3 = cls._create_picking_batch(cls.product_a)
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 640fc1aa7a..1ff1fefe84 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -145,7 +145,7 @@ def test_start_package_not_in_src_location(self):
"message": "You cannot work on a package (%s) outside of location: %s"
% (
self.pack_a.name,
- self.process.picking_type_ids.default_location_src_id.name,
+ self.process.picking_type_id.default_location_src_id.name,
),
},
)
@@ -239,7 +239,7 @@ def _simulate_started(self):
Used to test the next endpoints (/validate and /cancel)
"""
picking_form = Form(self.env["stock.picking"])
- picking_form.picking_type_id = self.menu.process_id.picking_type_ids
+ picking_form.picking_type_id = self.menu.process_id.picking_type_id
with picking_form.move_ids_without_package.new() as move:
move.product_id = self.product_a
move.product_uom_qty = 1
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index acbd4aef88..7679bef1b7 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -25,7 +25,7 @@ def setUpClass(cls, *args, **kwargs):
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
- cls.picking_type = cls.process.picking_type_ids
+ cls.picking_type = cls.process.picking_type_id
cls.picking = cls._create_initial_move()
# disable the completion on the picking type, we'll have specific test(s)
diff --git a/shopfloor/views/shopfloor_process.xml b/shopfloor/views/shopfloor_process.xml
index db58ed0b34..25821c60fc 100644
--- a/shopfloor/views/shopfloor_process.xml
+++ b/shopfloor/views/shopfloor_process.xml
@@ -7,7 +7,7 @@
-
+
@@ -24,7 +24,7 @@
-
+
@@ -39,6 +39,7 @@
+
diff --git a/shopfloor/views/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml
index 1ccb8c591b..c5ba2996ff 100644
--- a/shopfloor/views/stock_picking_type.xml
+++ b/shopfloor/views/stock_picking_type.xml
@@ -6,7 +6,7 @@
-
+
From 9a79da04a775a1e60ce9127b076f0e1e97c8702e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 18 Feb 2020 14:45:41 +0100
Subject: [PATCH 094/940] app api: fix response by converting to json
Previously, _search was converting to json, which is no longer
the case since 186bb7b so we can use _search internally.
---
shopfloor/services/app.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py
index 80bfbfd444..997c33eda4 100644
--- a/shopfloor/services/app.py
+++ b/shopfloor/services/app.py
@@ -10,8 +10,10 @@ class ShopfloorApp(Component):
_description = __doc__
def user_config(self):
- menus = self.component("menu")._search()
- profiles = self.component("profile")._search()
+ menu_comp = self.component("menu")
+ profiles_comp = self.component("profile")
+ menus = menu_comp._to_json(menu_comp._search())
+ profiles = profiles_comp._to_json(profiles_comp._search())
return self._response(data={"menus": menus, "profiles": profiles})
From d6686ab34e499a1d0cf09d1a49807f210154ed6f Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 18 Feb 2020 15:20:34 +0100
Subject: [PATCH 095/940] backend tests: simplify asserts using self.ANY
---
shopfloor/tests/common.py | 64 ++++++++-------------------------------
1 file changed, 12 insertions(+), 52 deletions(-)
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 386d0c4956..bae79cc4ce 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -1,5 +1,4 @@
from contextlib import contextmanager
-from copy import deepcopy
from pprint import pformat
from odoo.tests.common import SavepointCase
@@ -19,12 +18,17 @@ def __deepcopy__(self, memodict=None):
def __copy__(self):
return self
+ def __eq__(self, other):
+ return True
+
class CommonCase(SavepointCase, ComponentMixin):
# by default disable tracking suite-wise, it's a time saver :)
tracking_disable = True
+ ANY = AnyObject()
+
@contextmanager
def work_on_services(self, **params):
params = params or {}
@@ -66,8 +70,7 @@ def setUpClassVars(cls):
def assert_response(self, response, next_state=None, message=None, data=None):
"""Assert a response from the webservice
- The data and message dictionaries are checked using
- ``self.assert_dict``, which means we can use ``self.ANY`` to accept any
+ The data and message dictionaries can use ``self.ANY`` to accept any
value.
"""
expected = {}
@@ -79,55 +82,12 @@ def assert_response(self, response, next_state=None, message=None, data=None):
)
elif data:
expected["data"] = data
- self.assert_dict(response, expected)
-
- ANY = AnyObject()
-
- def assert_dict(self, current, expected):
- """Assert dictionary equality with support of wildcard values
-
- In the expected dictionary, instead of a value, ``self.ANY``
- can be provided, which will accept any value.
-
- For instance, an expected value for a response which accepts any
- content could be::
-
- {
- "data": self.ANY,
- "message": {
- "message_type": self.ANY,
- "message": self.ANY,
- },
- "next_state": self.ANY,
- }
-
- Note: if ``self.ANY`` is used, the key must exist in the dictionary
- to check.
- """
- next_checks = [(current, expected)]
- while next_checks:
- original, node_original_expected = next_checks.pop()
- node_values = deepcopy(original)
- node_expected = deepcopy(node_original_expected)
-
- for key in original:
- # sub-dictionaries will be checked later
- expected_value = node_expected.get(key)
- if expected_value is self.ANY:
- # ignore 'any' keys
- node_values.pop(key)
- node_expected.pop(key)
- continue
- if isinstance(expected_value, dict):
- next_checks.append((node_values.pop(key), node_expected.pop(key)))
- continue
-
- self.assertDictEqual(
- node_values,
- node_expected,
- "\n\nActual:\n%s"
- "\n\nExpected:\n%s" % (pformat(current), pformat(expected)),
- )
+ self.assertDictEqual(
+ response,
+ expected,
+ "\n\nActual:\n%s"
+ "\n\nExpected:\n%s" % (pformat(response), pformat(expected)),
+ )
def _update_qty_in_location(self, location, product, quantity):
self.env["stock.quant"]._update_available_quantity(product, location, quantity)
From e9fa321943707294d98e1d5b0bbfc45f3d9edeb9 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 18 Feb 2020 15:28:14 +0100
Subject: [PATCH 096/940] backend: add tests for app/menu/profiles search
---
shopfloor/tests/__init__.py | 3 ++
shopfloor/tests/test_app.py | 51 +++++++++++++++++++++++++++++++++
shopfloor/tests/test_menu.py | 40 ++++++++++++++++++++++++++
shopfloor/tests/test_profile.py | 35 ++++++++++++++++++++++
4 files changed, 129 insertions(+)
create mode 100644 shopfloor/tests/test_app.py
create mode 100644 shopfloor/tests/test_menu.py
create mode 100644 shopfloor/tests/test_profile.py
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index b9d6b8a20e..91bcbf4ea5 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -1,3 +1,6 @@
+from . import test_app
+from . import test_menu
+from . import test_profile
from . import test_picking_batch
from . import test_single_pack_putaway
from . import test_single_pack_transfer
diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py
new file mode 100644
index 0000000000..e431ba588b
--- /dev/null
+++ b/shopfloor/tests/test_app.py
@@ -0,0 +1,51 @@
+from .common import CommonCase
+
+
+class AppCase(CommonCase):
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services() as work:
+ self.service = work.component(usage="app")
+
+ def test_to_openapi(self):
+ # will raise if it fails to generate the openapi specs
+ self.service.to_openapi()
+
+ def test_user_config(self):
+ """Request /app/user_config"""
+ # Simulate the client asking the configuration
+ response = self.service.dispatch("user_config")
+ self.assert_response(
+ response,
+ data={
+ "menus": [
+ {
+ "id": self.ANY,
+ "name": "Put-Away Reach Truck",
+ "process": {"id": self.ANY, "code": "single_pack_putaway"},
+ },
+ {
+ "id": self.ANY,
+ "name": "Single Pallet Transfer",
+ "process": {"id": self.ANY, "code": "single_pack_transfer"},
+ },
+ {
+ "id": self.ANY,
+ "name": "Cluster Picking",
+ "process": {"id": self.ANY, "code": "cluster_picking"},
+ },
+ ],
+ "profiles": [
+ {
+ "id": self.ANY,
+ "name": "Highbay Truck",
+ "warehouse": {"id": self.ANY, "name": "YourCompany"},
+ },
+ {
+ "id": self.ANY,
+ "name": "Shelf 1",
+ "warehouse": {"id": self.ANY, "name": "YourCompany"},
+ },
+ ],
+ },
+ )
diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py
new file mode 100644
index 0000000000..98952c5c2e
--- /dev/null
+++ b/shopfloor/tests/test_menu.py
@@ -0,0 +1,40 @@
+from .common import CommonCase
+
+
+class MenuCase(CommonCase):
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services() as work:
+ self.service = work.component(usage="menu")
+
+ def test_to_openapi(self):
+ # will raise if it fails to generate the openapi specs
+ self.service.to_openapi()
+
+ def test_menu_search(self):
+ """Request /menu/search"""
+ # Simulate the client searching menus
+ response = self.service.dispatch("search")
+ self.assert_response(
+ response,
+ data={
+ "size": 3,
+ "records": [
+ {
+ "id": self.ANY,
+ "name": "Put-Away Reach Truck",
+ "process": {"id": self.ANY, "code": "single_pack_putaway"},
+ },
+ {
+ "id": self.ANY,
+ "name": "Single Pallet Transfer",
+ "process": {"id": self.ANY, "code": "single_pack_transfer"},
+ },
+ {
+ "id": self.ANY,
+ "name": "Cluster Picking",
+ "process": {"id": self.ANY, "code": "cluster_picking"},
+ },
+ ],
+ },
+ )
diff --git a/shopfloor/tests/test_profile.py b/shopfloor/tests/test_profile.py
new file mode 100644
index 0000000000..7a2fc1f4e0
--- /dev/null
+++ b/shopfloor/tests/test_profile.py
@@ -0,0 +1,35 @@
+from .common import CommonCase
+
+
+class ProfileCase(CommonCase):
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services() as work:
+ self.service = work.component(usage="profile")
+
+ def test_to_openapi(self):
+ # will raise if it fails to generate the openapi specs
+ self.service.to_openapi()
+
+ def test_profile_search(self):
+ """Request /profile/search"""
+ # Simulate the client searching profiles
+ response = self.service.dispatch("search")
+ self.assert_response(
+ response,
+ data={
+ "size": 2,
+ "records": [
+ {
+ "id": self.ANY,
+ "name": "Highbay Truck",
+ "warehouse": {"id": self.ANY, "name": "YourCompany"},
+ },
+ {
+ "id": self.ANY,
+ "name": "Shelf 1",
+ "warehouse": {"id": self.ANY, "name": "YourCompany"},
+ },
+ ],
+ },
+ )
From 20868ea41d0356946fbf68550b220e87d2e4b422 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 20 Feb 2020 10:14:08 +0100
Subject: [PATCH 097/940] rest api: Start documenting Checkout service
---
shopfloor/services/__init__.py | 12 +-
shopfloor/services/checkout.py | 324 +++++++++++++++++++++++++++++++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_checkout.py | 28 +++
4 files changed, 362 insertions(+), 3 deletions(-)
create mode 100644 shopfloor/services/checkout.py
create mode 100644 shopfloor/tests/test_checkout.py
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 7a137fed0d..6a8054d8e0 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -1,11 +1,17 @@
+# core classes
from . import service
from . import validator
+
+# generic services
from . import app
-from . import profile
+from . import location
from . import menu
from . import pack
-from . import location
+from . import profile
+
+# process services
+from . import checkout
from . import cluster_picking
+from . import picking_batch
from . import single_pack_putaway
from . import single_pack_transfer
-from . import picking_batch
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
new file mode 100644
index 0000000000..6e2c95d8b4
--- /dev/null
+++ b/shopfloor/services/checkout.py
@@ -0,0 +1,324 @@
+from odoo.addons.base_rest.components.service import to_int
+from odoo.addons.component.core import Component
+
+from .service import to_float
+
+
+class Checkout(Component):
+ """
+ Methods for the Checkout Process
+
+ This process runs on existing moves.
+ It happens on the "Packing" step of a pick/pack/ship.
+
+ Use cases:
+
+ 1) Products are packed (e.g. full pallet shipping) and we keep the packages
+ 2) Products are packed (e.g. rollercage bins) and we create a new package
+ with same content for shipping
+ 3) Products are packed (e.g. half-pallet ) and we merge several into one
+ 4) Products are packed (e.g. too high pallet) and we split it on several
+ 5) Products are not packed (e.g. raw products) and we create new packages
+ 6) Products are not packed (e.g. raw products) and we do not create packages
+
+ Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
+ """
+
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.checkout"
+ _usage = "checkout"
+ _description = __doc__
+
+ def scan_document(self, barcode):
+ """Scan a package, a stock.picking or a location
+
+ When a location is scanned, if all the move lines from this destination
+ are for the same stock.picking, the stock.picking is used for the
+ next steps.
+
+ When a package is scanned, if the package has a move line to move it
+ from a location/sublocation of the current stock.picking.type, the
+ stock.picking for the package is used for the next steps.
+
+ When a stock.picking is scanned, it is used for the next steps.
+
+ In every case above, the stock.picking must be entirely available and
+ must match the current picking type.
+
+ Transitions:
+ * select_document: when no stock.picking could be found
+ * select_line: a stock.picking is selected
+ """
+ return self._response()
+
+ def list_stock_picking(self):
+ """List stock.picking records available
+
+ Returns a list of all the available records for the current picking
+ type.
+
+ Transitions:
+ * manual_selection: to the selection screen
+ """
+ return self._response()
+
+ def select(self, picking_id):
+ """Select a stock picking for the process
+
+ Used from the list of stock pickings (manual_selection), from there,
+ the user can click on a stock.picking record which calls this method.
+
+ The ``list_stock_picking`` returns only the valid records (same picking
+ type, fully available, ...), but this method has to check again in case
+ something changed since the list was sent to the client.
+
+ Transitions:
+ * manual_selection: stock.picking could finally not be selected (not
+ available, ...)
+ * summary: goes straight to this state used to set the moves as done when
+ all the move lines with a reserved quantity have a 'quantity done'
+ * select_line: the "normal" case, when the user has to put in pack/move
+ lines
+ """
+ return self._response()
+
+ def scan_line(self, picking_id, barcode):
+ """Scan move lines of the stock picking
+
+ It allows to select move lines of the stock picking for the next
+ screen. Lines can be found either by scanning a package, a product or a
+ lot.
+
+ There should be no ambiguity, so for instance if a product is scanned but
+ several packs contain it, the endpoint will ask to scan a pack; if the
+ product is tracked by lot, to scan a lot.
+
+ Once move lines are found, their ``qty_done`` is set to their reserved
+ quantity.
+
+ Transitions:
+ * select_line: nothing could be found for the barcode
+ * select_pack: lines are selected, user is redirected to this
+ screen to change the qty done and destination pack if needed
+ """
+ return self._response()
+
+ def select_line(self, picking_id, package_id=None, move_line_id=None):
+ """Select move lines of the stock picking
+
+ This is the same as ``scan_line``, except that a package id or a
+ move_line_id is given by the client (user clicked on a list).
+
+ It returns a list of move line ids that will be displayed by the
+ screen ``select_pack``. This screen will have to send this list to
+ the endpoints it calls, so we can select/deselect lines but still
+ show them in the list of the client application.
+
+ Transitions:
+ * select_line: nothing could be found for the barcode
+ * select_pack: lines are selected, user is redirected to this
+ screen to change the qty done and destination pack if needed
+ """
+ assert package_id or move_line_id
+ return self._response()
+
+ def reset_line_qty(self, move_line_id):
+ """Reset qty_done of a move line to zero
+
+ Used to deselect a line in the "select_pack" screen.
+
+ Transitions:
+ * select_pack: goes back to the same state, the line will appear
+ as deselected
+ """
+ return self._response()
+
+ def set_line_qty(self, move_line_id):
+ """Set qty_done of a move line to its reserved quantity
+
+ Used to deselect a line in the "select_pack" screen.
+
+ Transitions:
+ * select_pack: goes back to the same state, the line will appear
+ as selected
+ """
+ return self._response()
+
+ def scan_pack_action(self, move_line_ids, barcode):
+ """Scan a package, a lot, a product or a package to handle a line
+
+ When a package is scanned, if the package is known as the destination
+ package of one of the lines or is the source package of a selected
+ line, the package is set to be the destination package of all then
+ selected lines.
+
+ When a product is scanned, it selects (set qty_done = reserved qty) or
+ deselects (set qty_done = 0) the move lines for this product. Only
+ products not tracked by lot can use this.
+
+ When a lot is scanned, it does the same as for the products but based
+ on the lot.
+
+ When a packaging type (one without related product) is scanned, a new
+ package is created and set as destination of the selected lines.
+
+ Selected lines are move lines in the list of ``move_line_ids`` where
+ ``qty_done`` > 0 and have no destination package.
+
+ Transitions:
+ * select_pack: when a product or lot is scanned to select/deselect,
+ the client app has to show the same screen with the updated selection
+ * select_line: when a package or packaging type is scanned, move lines
+ have been put in package and we can return back to this state to handle
+ the other lines
+ * summary: if there is no other lines, go to the summary screen to be able
+ to close the stock picking
+ """
+ return self._response()
+
+ def set_custom_qty(self, move_line_id, qty_done):
+ """Change qty_done of a move line with a custom value
+
+ Transitions:
+ * select_pack: goes back to this screen showing all the lines after
+ we changed the qty
+ """
+ return self._response()
+
+ def new_package(self, move_line_ids):
+ """Add all selected lines in a new package
+
+ It creates a new package and set it as the destination package of all
+ the selected lines.
+
+ Selected lines are move lines in the list of ``move_line_ids`` where
+ ``qty_done`` > 0 and have no destination package.
+
+ Transitions:
+ * select_line: goes back to selection of lines to work on next lines
+ """
+ return self._response()
+
+ # TODO add the rest of the methods
+
+
+class ShopfloorCheckoutValidator(Component):
+ """Validators for the Checkout endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.checkout.validator"
+ _usage = "checkout.validator"
+
+ def scan_document(self):
+ return {"barcode": {"required": True, "type": "string"}}
+
+ def list_stock_picking(self):
+ return {}
+
+ def select(self):
+ return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def scan_line(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def select_line(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_id": {"coerce": to_int, "required": False, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": False, "type": "integer"},
+ }
+
+ def reset_line_qty(self):
+ return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def set_line_qty(self):
+ return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def scan_pack_action(self):
+ return {
+ "move_line_ids": {
+ "type": "list",
+ "required": True,
+ "schema": {"coerce": to_int, "required": True, "type": "integer"},
+ },
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def set_custom_qty(self):
+ return {
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "qty_done": {"coerce": to_float, "required": True, "type": "float"},
+ }
+
+ def new_package(self):
+ return {
+ "move_line_ids": {
+ "type": "list",
+ "required": True,
+ "schema": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+ }
+
+
+class ShopfloorCheckoutValidatorResponse(Component):
+ """Validators for the Checkout endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.checkout.validator.response"
+ _usage = "checkout.validator.response"
+
+ def _states(self):
+ """List of possible next states
+
+ With the schema of the data send to the client to transition
+ to the next state.
+ """
+ # TODO schemas
+ return {
+ "select_document": {},
+ "manual_selection": {},
+ "select_line": {},
+ "select_pack": {},
+ "change_quantity": {},
+ "select_dest_package": {},
+ "summary": {},
+ "change_package_type": {},
+ "confirm_done": {},
+ }
+
+ def scan_document(self):
+ return self._response_schema(next_states=["select_document", "select_line"])
+
+ def list_stock_picking(self):
+ return self._response_schema(next_states=["manual_selection"])
+
+ def select(self):
+ return self._response_schema(
+ next_states=["manual_selection", "summary", "select_line"]
+ )
+
+ def scan_line(self):
+ return self._response_schema(next_states=["select_line", "select_pack"])
+
+ def select_line(self):
+ return self.scan_line()
+
+ def reset_line_qty(self):
+ return self._response_schema(next_states=["select_pack"])
+
+ def set_line_qty(self):
+ return self._response_schema(next_states=["select_pack"])
+
+ def scan_pack_action(self):
+ return self._response_schema(
+ next_states=["select_pack", "select_line", "summary"]
+ )
+
+ def set_custom_qty(self):
+ return self._response_schema(next_states=["select_pack"])
+
+ def new_package(self):
+ return self._response_schema(next_states=["select_line"])
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 91bcbf4ea5..e4331331dd 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -5,3 +5,4 @@
from . import test_single_pack_putaway
from . import test_single_pack_transfer
from . import test_cluster_picking
+from . import test_checkout
diff --git a/shopfloor/tests/test_checkout.py b/shopfloor/tests/test_checkout.py
new file mode 100644
index 0000000000..7cd5133cdd
--- /dev/null
+++ b/shopfloor/tests/test_checkout.py
@@ -0,0 +1,28 @@
+from .common import CommonCase
+
+
+class CheckoutCase(CommonCase):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.product_a = cls.env["product.product"].create(
+ {"name": "Product A", "type": "product"}
+ )
+ cls.product_b = cls.env["product.product"].create(
+ {"name": "Product B", "type": "product"}
+ )
+ # TODO
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
+ cls.process = cls.menu.process_id
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.picking_type = cls.process.picking_type_id
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
+ self.service = work.component(usage="checkout")
+
+ def test_to_openapi(self):
+ # will raise if it fails to generate the openapi specs
+ self.service.to_openapi()
From fd8b843458d1dc1017098ca1aae44003cea13d34 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 20 Feb 2020 11:36:23 +0100
Subject: [PATCH 098/940] cluster_picking: add list_batch endpoint
---
shopfloor/services/cluster_picking.py | 26 +++++++++++++-
shopfloor/services/picking_batch.py | 9 +++--
shopfloor/tests/test_cluster_picking.py | 46 +++++++++++++++++++++++++
3 files changed, 75 insertions(+), 6 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 7ec38bcfef..9de530828c 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -95,6 +95,19 @@ def find_batch(self):
else:
return self._response_for_no_batch_found()
+ def list_batch(self):
+ """List picking batch on which user can work
+
+ Returns a list of all the available records for the current picking
+ type.
+
+ Transitions:
+ * manual_selection: to the selection screen
+ """
+ batch_service = self.component(usage="picking_batch")
+ batches = batch_service.search()["data"]
+ return self._response(next_state="manual_selection", data=batches)
+
# TODO this may be used in other scenarios? if so, extract
def _select_a_picking_batch(self, batches):
# look for in progress + assigned to self first
@@ -454,6 +467,9 @@ class ShopfloorClusterPickingValidator(Component):
def find_batch(self):
return {}
+ def list_batch(self):
+ return {}
+
def select(self):
return {
"picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
@@ -553,7 +569,7 @@ def _states(self):
"confirm_start": self._schema_for_batch_details,
"start_line": self._schema_for_single_line_details,
"start": {},
- "manual_selection": {},
+ "manual_selection": self._schema_for_batch_selection,
"scan_destination": self._schema_for_single_line_details,
"zero_check": self._schema_for_zero_check,
"unload_all": self._schema_for_unload_all,
@@ -567,6 +583,9 @@ def _states(self):
def find_batch(self):
return self._response_schema(next_states=["confirm_start", "start"])
+ def list_batch(self):
+ return self._response_schema(next_states=["manual_selection"])
+
def select(self):
return self._response_schema(next_states=["manual_selection", "confirm_start"])
@@ -849,3 +868,8 @@ def _schema_for_completion_info(self):
"picking_done": {"type": "string", "nullable": False, "required": True},
"picking_next": {"type": "string", "nullable": False, "required": True},
}
+
+ @property
+ def _schema_for_batch_selection(self):
+ batch_validator = self.component(usage="picking_batch.validator.response")
+ return batch_validator.search()["data"]["schema"]
diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py
index 83cb0b2d71..2838373dc3 100644
--- a/shopfloor/services/picking_batch.py
+++ b/shopfloor/services/picking_batch.py
@@ -1,6 +1,5 @@
from odoo.osv import expression
-from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -104,7 +103,7 @@ class ShopfloorPickingBatchValidatorResponse(Component):
def search(self):
return self._response_schema(
{
- "size": {"coerce": to_int, "required": True, "type": "integer"},
+ "size": {"required": True, "type": "integer"},
"records": {
"type": "list",
"required": True,
@@ -116,8 +115,8 @@ def search(self):
@property
def _record_schema(self):
return {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
- "picking_count": {"coerce": to_int, "required": True, "type": "integer"},
- "move_line_count": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_count": {"required": True, "type": "integer"},
+ "move_line_count": {"required": True, "type": "integer"},
}
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 7b12480303..e32c957a93 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -18,9 +18,12 @@ def setUpClass(cls, *args, **kwargs):
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
cls.picking_type = cls.process.picking_type_id
+ # drop base demo data and create our own batches to work with
+ cls.env["stock.picking.batch"].search([]).unlink()
cls.batch1 = cls._create_picking_batch(cls.product_a)
cls.batch2 = cls._create_picking_batch(cls.product_a)
cls.batch3 = cls._create_picking_batch(cls.product_a)
+ cls.batch4 = cls._create_picking_batch(cls.product_a)
def setUp(self):
super().setUp()
@@ -180,6 +183,49 @@ def test_find_batch_not_found(self):
},
)
+ def test_list_batch(self):
+ """List all available batches"""
+ # batches must have all their pickings available to be selected
+ self._add_stock_and_assign_pickings_for_batches(
+ self.batch1 | self.batch2 | self.batch3
+ )
+ self.batch1.write({"state": "in_progress", "user_id": self.env.uid})
+ self.batch2.write(
+ {"state": "in_progress", "user_id": self.env.ref("base.user_demo")}
+ )
+ self.batch3.write({"state": "draft", "user_id": False})
+
+ self.assertEqual(
+ self.env["stock.picking.batch"].search([]),
+ self.batch1 + self.batch2 + self.batch3 + self.batch4,
+ )
+ # Simulate the client asking the list of batches
+ response = self.service.dispatch("list_batch")
+ self.assert_response(
+ response,
+ next_state="manual_selection",
+ data={
+ "size": 2,
+ "records": [
+ {
+ "id": self.batch1.id,
+ "name": self.batch1.name,
+ "picking_count": 1,
+ "move_line_count": 1,
+ },
+ # batch 2 is excluded because assigned to someone else
+ {
+ "id": self.batch3.id,
+ "name": self.batch3.name,
+ "picking_count": 1,
+ "move_line_count": 1,
+ },
+ # batch 4 is excluded because not all of its pickings are
+ # assigned
+ ],
+ },
+ )
+
def test_select_in_progress_assigned(self):
"""Select an in-progress batch assigned to the current user"""
self._add_stock_and_assign_pickings_for_batches(self.batch1)
From 846e3c510af85fb0585b05f208638b50cab6b27f Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 20 Feb 2020 14:47:03 +0100
Subject: [PATCH 099/940] cluster_picking: add /confirm_start
Refactor cluster_picking tests along the way to share "pre-conditions" setup
for endpoints.
---
shopfloor/actions/message.py | 6 +
shopfloor/services/cluster_picking.py | 75 ++++++++++-
shopfloor/services/service.py | 9 +-
shopfloor/tests/common.py | 39 +++++-
shopfloor/tests/test_cluster_picking.py | 159 +++++++++++++++++++-----
shopfloor/tests/test_picking_batch.py | 23 +---
6 files changed, 245 insertions(+), 66 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 98557b39cd..0cc3d56ee2 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -59,6 +59,12 @@ def operation_not_found(self):
"message": _("This operation does not exist anymore."),
}
+ def record_not_found(self):
+ return {
+ "message_type": "error",
+ "message": _("This record you were working on does not exist anymore."),
+ }
+
def operation_has_been_canceled_elsewhere(self):
return {
"message_type": "warning",
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 9de530828c..65d517f896 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -167,7 +167,7 @@ def _response_for_confirm_start(self, batch):
def _response_for_batch_cannot_be_selected(self):
return self._response(
- next_state="manual_selection",
+ base_response=self.list_batch(),
message={
"message_type": "warning",
"message": _("This batch cannot be selected."),
@@ -211,7 +211,61 @@ def confirm_start(self, picking_batch_id):
package
* start: if the condition above is wrong (rare case of race condition...)
"""
- return self._response()
+ picking_batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not picking_batch.exists():
+ return self._response_batch_does_not_exist()
+
+ remaining_lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(
+ lambda l: l.state == "assigned"
+ )
+ if not remaining_lines:
+ # TODO
+ pass
+
+ return self._response(
+ next_state="start_line", data=self._data_for_next_move_line(remaining_lines)
+ )
+
+ def _response_batch_does_not_exist(self):
+ message = self.actions_for("message")
+ return self._response(next_state="start", message=message.record_not_found())
+
+ def _data_for_next_move_line(self, move_lines):
+ line = move_lines[0]
+ picking = line.picking_id
+ batch = picking.batch_id
+ product = line.product_id
+ lot = line.lot_id
+ package = line.package_id
+ return {
+ # TODO have common methods to return general info
+ # for each model
+ "id": line.id,
+ "quantity": line.product_uom_qty,
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "origin": picking.origin or "",
+ "note": picking.note or "",
+ },
+ "batch": {"id": batch.id, "name": batch.name},
+ "product": {
+ "id": product.id,
+ "name": product.name,
+ "display_name": product.display_name,
+ "default_code": product.default_code or "",
+ "qty_available": product.qty_available,
+ },
+ "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""}
+ if lot
+ else None,
+ "location_src": {"id": line.location_id.id, "name": line.location_id.name},
+ "location_dst": {
+ "id": line.location_dest_id.id,
+ "name": line.location_dest_id.name,
+ },
+ "pack": {"id": package.id, "name": package.name} if package else None,
+ }
def unassign(self, picking_batch_id):
"""Unassign and reset to draft a started picking batch
@@ -754,6 +808,7 @@ def _schema_for_single_line_details(self):
return {
# id is a stock.move.line
"id": {"required": True, "type": "integer"},
+ "quantity": {"type": "float", "required": True},
"picking": {
"type": "dict",
"schema": {
@@ -770,13 +825,21 @@ def _schema_for_single_line_details(self):
"name": {"type": "string", "nullable": False, "required": True},
},
},
- "quantity": {"type": "float", "required": True},
"product": {
"type": "dict",
"schema": {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
- "ref": {"type": "string", "nullable": False, "required": True},
+ "display_name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ "default_code": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
"qty_available": {
"type": "float",
"nullable": False,
@@ -786,6 +849,8 @@ def _schema_for_single_line_details(self):
},
"lot": {
"type": "dict",
+ "required": False,
+ "nullable": True,
"schema": {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
@@ -809,6 +874,8 @@ def _schema_for_single_line_details(self):
},
"pack": {
"type": "dict",
+ "required": False,
+ "nullable": True,
"schema": {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index cd2638fc2e..2d82ba2f1a 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -76,11 +76,13 @@ def _get_output_validator(self, method_name):
)
return validator_component._get_validator(method_name)
- def _response(self, data=None, next_state=None, message=None):
+ def _response(self, base_response=None, data=None, next_state=None, message=None):
"""Base "envelope" for the responses
All the keys are optional.
+ :param base_response: optional dictionary of values to extend
+ (typically already created by a call to _response())
:param data: dictionary of values, when a next_state is provided,
the data is enclosed in a key of the same name (to support polymorphism
in the schema)
@@ -89,7 +91,10 @@ def _response(self, data=None, next_state=None, message=None):
:param message: dictionary for the message to show in the client
application (see ``_response_schema`` for the keys)
"""
- response = {}
+ if base_response:
+ response = base_response.copy()
+ else:
+ response = {}
if next_state:
# data for a state is always enclosed in a key with the name
# of the state, so an endpoint can return to different states
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index bae79cc4ce..06f866967e 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -1,7 +1,7 @@
from contextlib import contextmanager
from pprint import pformat
-from odoo.tests.common import SavepointCase
+from odoo.tests.common import Form, SavepointCase
from odoo.addons.base_rest.controllers.main import _PseudoCollection
from odoo.addons.component.core import WorkContext
@@ -89,14 +89,43 @@ def assert_response(self, response, next_state=None, message=None, data=None):
"\n\nExpected:\n%s" % (pformat(response), pformat(expected)),
)
- def _update_qty_in_location(self, location, product, quantity):
- self.env["stock.quant"]._update_available_quantity(product, location, quantity)
+ @classmethod
+ def _update_qty_in_location(cls, location, product, quantity):
+ cls.env["stock.quant"]._update_available_quantity(product, location, quantity)
- def _fill_stock_for_pickings(self, pickings):
+ @classmethod
+ def _fill_stock_for_pickings(cls, pickings):
product_locations = {}
for move in pickings.mapped("move_lines"):
key = (move.product_id, move.location_id)
product_locations.setdefault(key, 0)
product_locations[key] += move.product_qty
for (product, location), qty in product_locations.items():
- self._update_qty_in_location(location, product, qty)
+ cls._update_qty_in_location(location, product, qty)
+
+
+class PickingBatchMixin:
+ @classmethod
+ def _create_picking_batch(cls, product):
+ picking_form = Form(cls.env["stock.picking"])
+ picking_form.picking_type_id = cls.picking_type
+ picking_form.location_id = cls.stock_location
+ picking_form.location_dest_id = cls.packing_location
+ picking_form.origin = "test {}".format(product.name)
+ picking_form.partner_id = cls.customer
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = product
+ move.product_uom_qty = 1
+ picking = picking_form.save()
+ picking.action_confirm()
+ picking.action_assign()
+
+ batch_form = Form(cls.env["stock.picking.batch"])
+ batch_form.picking_ids.add(picking)
+ return batch_form.save()
+
+ @classmethod
+ def _add_stock_and_assign_pickings_for_batches(cls, batches):
+ pickings = batches.mapped("picking_ids")
+ cls._fill_stock_for_pickings(pickings)
+ pickings.action_assign()
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index e32c957a93..d485ce770e 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -1,29 +1,23 @@
-from odoo.tests.common import Form
+import unittest
-from .common import CommonCase
+from .common import CommonCase, PickingBatchMixin
-class ClusterPickingCase(CommonCase):
+class ClusterPickingCommonCase(CommonCase, PickingBatchMixin):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
cls.product_a = cls.env["product.product"].create(
- {"name": "Product A", "type": "product"}
+ {"name": "Product A", "type": "product", "default_code": "A"}
)
cls.product_b = cls.env["product.product"].create(
- {"name": "Product B", "type": "product"}
+ {"name": "Product B", "type": "product", "default_code": "B"}
)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
cls.picking_type = cls.process.picking_type_id
- # drop base demo data and create our own batches to work with
- cls.env["stock.picking.batch"].search([]).unlink()
- cls.batch1 = cls._create_picking_batch(cls.product_a)
- cls.batch2 = cls._create_picking_batch(cls.product_a)
- cls.batch3 = cls._create_picking_batch(cls.product_a)
- cls.batch4 = cls._create_picking_batch(cls.product_a)
def setUp(self):
super().setUp()
@@ -31,32 +25,43 @@ def setUp(self):
self.service = work.component(usage="cluster_picking")
@classmethod
- def _create_picking_batch(cls, product):
- picking_form = Form(cls.env["stock.picking"])
- picking_form.picking_type_id = cls.picking_type
- picking_form.location_id = cls.stock_location
- picking_form.location_dest_id = cls.packing_location
- picking_form.origin = "test {}".format(product.name)
- picking_form.partner_id = cls.customer
- with picking_form.move_ids_without_package.new() as move:
- move.product_id = product
- move.product_uom_qty = 1
- picking = picking_form.save()
- picking.action_confirm()
- picking.action_assign()
-
- batch_form = Form(cls.env["stock.picking.batch"])
- batch_form.picking_ids.add(picking)
- return batch_form.save()
+ def _simulate_batch_selected(cls, batches):
+ cls._add_stock_and_assign_pickings_for_batches(batches)
+ batches.write({"state": "in_progress", "user_id": cls.env.uid})
+
+
+class ClusterPickingAPICase(ClusterPickingCommonCase):
+ """Base tests for the cluster picking API"""
def test_to_openapi(self):
# will raise if it fails to generate the openapi specs
self.service.to_openapi()
- def _add_stock_and_assign_pickings_for_batches(self, batches):
- pickings = batches.mapped("picking_ids")
- self._fill_stock_for_pickings(pickings)
- pickings.action_assign()
+
+class ClusterPickingSelectionCase(ClusterPickingCommonCase):
+ """Tests covering the selection of picking batches
+
+ Endpoints:
+
+ * /cluster_picking/find_batch
+ * /cluster_picking/list_batch
+ * /cluster_picking/select
+ * /cluster_picking/unassign
+
+ These endpoints interact with a list of picking batches.
+ The other endpoints that interact with a single batch (after selection)
+ are handled in ``ClusterPickingSelectedCase``.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # drop base demo data and create our own batches to work with
+ cls.env["stock.picking.batch"].search([]).unlink()
+ cls.batch1 = cls._create_picking_batch(cls.product_a)
+ cls.batch2 = cls._create_picking_batch(cls.product_a)
+ cls.batch3 = cls._create_picking_batch(cls.product_a)
+ cls.batch4 = cls._create_picking_batch(cls.product_a)
def test_find_batch_in_progress_current_user(self):
"""Find an in-progress batch assigned to the current user"""
@@ -309,6 +314,7 @@ def test_select_not_exists(self):
"message_type": "warning",
"message": "This batch cannot be selected.",
},
+ data={"size": 0, "records": []},
)
def test_select_already_assigned(self):
@@ -328,12 +334,12 @@ def test_select_already_assigned(self):
"message_type": "warning",
"message": "This batch cannot be selected.",
},
+ data={"size": 0, "records": []},
)
def test_unassign_batch(self):
"""User cancels after selecting a batch, unassign it"""
- self._add_stock_and_assign_pickings_for_batches(self.batch1)
- self.batch1.write({"state": "in_progress", "user_id": self.env.uid})
+ self._simulate_batch_selected(self.batch1)
# Simulate the client selecting the batch in a list
response = self.service.dispatch(
"unassign", params={"picking_batch_id": self.batch1.id}
@@ -351,3 +357,88 @@ def test_unassign_batch_not_exists(self):
"unassign", params={"picking_batch_id": batch_id}
)
self.assert_response(response, next_state="start")
+
+
+class ClusterPickingSelectedCase(ClusterPickingCommonCase):
+ """Tests covering endpoints working on a single picking batch
+
+ After a batch has been selected, by the tests covered in
+ ``ClusterPickingSelectionCase``.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # TODO add several lines / different products
+ cls.batch1 = cls._create_picking_batch(cls.product_a)
+ cls._simulate_batch_selected(cls.batch1)
+
+ def test_confirm_start_ok(self):
+ """User confirms she starts the selected picking batch (happy path)"""
+ # batch1 was already selected, we only need to confirm the selection
+ batch = self.batch1
+ self.assertEqual(batch.state, "in_progress")
+ picking = batch.picking_ids[0]
+ first_move_line = picking.move_line_ids[0]
+ self.assertTrue(first_move_line)
+
+ response = self.service.dispatch(
+ "confirm_start", params={"picking_batch_id": self.batch1.id}
+ )
+ self.assert_response(
+ response,
+ data={
+ "id": first_move_line.id,
+ "quantity": 1.0,
+ "location_dst": {
+ "id": first_move_line.location_dest_id.id,
+ "name": first_move_line.location_dest_id.name,
+ },
+ "location_src": {
+ "id": first_move_line.location_id.id,
+ "name": first_move_line.location_id.name,
+ },
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "note": "",
+ "origin": picking.origin,
+ },
+ "batch": {"id": batch.id, "name": batch.name},
+ "product": {
+ "default_code": first_move_line.product_id.default_code,
+ "display_name": first_move_line.product_id.display_name,
+ "id": first_move_line.product_id.id,
+ "name": first_move_line.product_id.name,
+ "qty_available": first_move_line.product_id.qty_available,
+ },
+ "lot": None,
+ "pack": None,
+ },
+ next_state="start_line",
+ )
+
+ def test_confirm_start_not_exists(self):
+ """User confirms she starts but batch has been deleted meanwhile"""
+ batch_id = self.batch1.id
+ self.batch1.unlink()
+ response = self.service.dispatch(
+ "confirm_start", params={"picking_batch_id": batch_id}
+ )
+ self.assert_response(
+ response,
+ message={
+ "message_type": "error",
+ "message": "This record you were working on does not exist anymore.",
+ },
+ next_state="start",
+ )
+
+ # TODO
+ @unittest.skip("not sure yet what we have to do, keep for later")
+ def test_confirm_start_all_is_done(self):
+ """User confirms start but all lines are already done"""
+ # we want to jump to the start because there are no lines
+ # to process anymore, but we want to set pickings and
+ # picking batch to done if not done yet (because the process
+ # was interrupted for instance)
diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py
index c11bee3a06..d053eefee4 100644
--- a/shopfloor/tests/test_picking_batch.py
+++ b/shopfloor/tests/test_picking_batch.py
@@ -1,9 +1,7 @@
-from odoo.tests.common import Form
+from .common import CommonCase, PickingBatchMixin
-from .common import CommonCase
-
-class BatchPickingCase(CommonCase):
+class BatchPickingCase(CommonCase, PickingBatchMixin):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
@@ -34,23 +32,6 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="picking_batch")
- @classmethod
- def _create_picking_batch(cls, product):
- picking_form = Form(cls.env["stock.picking"])
- picking_form.picking_type_id = cls.picking_type
- picking_form.location_id = cls.stock_location
- picking_form.location_dest_id = cls.packing_location
- with picking_form.move_ids_without_package.new() as move:
- move.product_id = product
- move.product_uom_qty = 1
- picking = picking_form.save()
- picking.action_confirm()
- picking.action_assign()
-
- batch_form = Form(cls.env["stock.picking.batch"])
- batch_form.picking_ids.add(picking)
- return batch_form.save()
-
def test_to_openapi(self):
# will raise if it fails to generate the openapi specs
self.service.to_openapi()
From 9ee4e0af075d81a396d908c79a2f0f03faf9ca4a Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 20 Feb 2020 15:13:29 +0100
Subject: [PATCH 100/940] backend tests: support multi-picking/move line in
helper to create batch
---
shopfloor/tests/common.py | 49 +++++++++++++++++--------
shopfloor/tests/test_cluster_picking.py | 30 ++++++++++-----
shopfloor/tests/test_picking_batch.py | 24 +++++++++---
3 files changed, 72 insertions(+), 31 deletions(-)
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 06f866967e..99bea23eb8 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -1,3 +1,4 @@
+from collections import namedtuple
from contextlib import contextmanager
from pprint import pformat
@@ -105,24 +106,42 @@ def _fill_stock_for_pickings(cls, pickings):
class PickingBatchMixin:
+
+ BatchProduct = namedtuple(
+ "BatchProduct",
+ # browse record of the product,
+ # quantity in float
+ "product quantity",
+ )
+
@classmethod
- def _create_picking_batch(cls, product):
- picking_form = Form(cls.env["stock.picking"])
- picking_form.picking_type_id = cls.picking_type
- picking_form.location_id = cls.stock_location
- picking_form.location_dest_id = cls.packing_location
- picking_form.origin = "test {}".format(product.name)
- picking_form.partner_id = cls.customer
- with picking_form.move_ids_without_package.new() as move:
- move.product_id = product
- move.product_uom_qty = 1
- picking = picking_form.save()
- picking.action_confirm()
- picking.action_assign()
+ def _create_picking_batch(cls, products):
+ """Create a picking batch
+ :param products: list of list of BatchProduct. The outer list creates
+ pickings and the innerr list creates moves in these pickings
+ """
batch_form = Form(cls.env["stock.picking.batch"])
- batch_form.picking_ids.add(picking)
- return batch_form.save()
+ for transfer in products:
+ picking_form = Form(cls.env["stock.picking"])
+ picking_form.picking_type_id = cls.picking_type
+ picking_form.location_id = cls.stock_location
+ picking_form.location_dest_id = cls.packing_location
+ picking_form.origin = "test"
+ picking_form.partner_id = cls.customer
+ for batch_product in transfer:
+ product = batch_product.product
+ quantity = batch_product.quantity
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = product
+ move.product_uom_qty = quantity
+ picking = picking_form.save()
+ batch_form.picking_ids.add(picking)
+
+ batch = batch_form.save()
+ batch.picking_ids.action_confirm()
+ batch.picking_ids.action_assign()
+ return batch
@classmethod
def _add_stock_and_assign_pickings_for_batches(cls, batches):
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index d485ce770e..061f316edc 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -58,10 +58,18 @@ def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
# drop base demo data and create our own batches to work with
cls.env["stock.picking.batch"].search([]).unlink()
- cls.batch1 = cls._create_picking_batch(cls.product_a)
- cls.batch2 = cls._create_picking_batch(cls.product_a)
- cls.batch3 = cls._create_picking_batch(cls.product_a)
- cls.batch4 = cls._create_picking_batch(cls.product_a)
+ cls.batch1 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+ cls.batch2 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+ cls.batch3 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+ cls.batch4 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
def test_find_batch_in_progress_current_user(self):
"""Find an in-progress batch assigned to the current user"""
@@ -370,20 +378,22 @@ class ClusterPickingSelectedCase(ClusterPickingCommonCase):
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
# TODO add several lines / different products
- cls.batch1 = cls._create_picking_batch(cls.product_a)
- cls._simulate_batch_selected(cls.batch1)
+ cls.batch = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+ cls._simulate_batch_selected(cls.batch)
def test_confirm_start_ok(self):
"""User confirms she starts the selected picking batch (happy path)"""
# batch1 was already selected, we only need to confirm the selection
- batch = self.batch1
+ batch = self.batch
self.assertEqual(batch.state, "in_progress")
picking = batch.picking_ids[0]
first_move_line = picking.move_line_ids[0]
self.assertTrue(first_move_line)
response = self.service.dispatch(
- "confirm_start", params={"picking_batch_id": self.batch1.id}
+ "confirm_start", params={"picking_batch_id": self.batch.id}
)
self.assert_response(
response,
@@ -420,8 +430,8 @@ def test_confirm_start_ok(self):
def test_confirm_start_not_exists(self):
"""User confirms she starts but batch has been deleted meanwhile"""
- batch_id = self.batch1.id
- self.batch1.unlink()
+ batch_id = self.batch.id
+ self.batch.unlink()
response = self.service.dispatch(
"confirm_start", params={"picking_batch_id": batch_id}
)
diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py
index d053eefee4..b40aa8726d 100644
--- a/shopfloor/tests/test_picking_batch.py
+++ b/shopfloor/tests/test_picking_batch.py
@@ -17,12 +17,24 @@ def setUpClass(cls, *args, **kwargs):
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
cls.picking_type = cls.process.picking_type_id
- cls.batch1 = cls._create_picking_batch(cls.product_a)
- cls.batch2 = cls._create_picking_batch(cls.product_a)
- cls.batch3 = cls._create_picking_batch(cls.product_a)
- cls.batch4 = cls._create_picking_batch(cls.product_b)
- cls.batch5 = cls._create_picking_batch(cls.product_b)
- cls.batch6 = cls._create_picking_batch(cls.product_b)
+ cls.batch1 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+ cls.batch2 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+ cls.batch3 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+ cls.batch4 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_b, quantity=1)]]
+ )
+ cls.batch5 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_b, quantity=1)]]
+ )
+ cls.batch6 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_b, quantity=1)]]
+ )
cls.all_batches = (
cls.batch1 + cls.batch2 + cls.batch3 + cls.batch4 + cls.batch5 + cls.batch6
)
From cce6a8dd6a38057e8deffb4a15bba9934dd09ab6 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 21 Feb 2020 11:43:12 +0100
Subject: [PATCH 101/940] cluster_picking: add /scan_line endpoint
---
shopfloor/actions/message.py | 27 +-
shopfloor/services/cluster_picking.py | 92 +++++-
shopfloor/tests/common.py | 30 +-
shopfloor/tests/test_cluster_picking.py | 317 ++++++++++++++++++-
shopfloor/tests/test_picking_batch.py | 2 +-
shopfloor/tests/test_single_pack_transfer.py | 2 +-
6 files changed, 447 insertions(+), 23 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 0cc3d56ee2..448e54c315 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -53,6 +53,12 @@ def already_running_ask_confirmation(self):
def scan_destination(self):
return {"message_type": "info", "message": _("Scan the destination location")}
+ def scan_lot_on_product_tracked_by_lot(self):
+ return {
+ "message_type": "warning",
+ "message": _("Product tracked by lot, please scan one."),
+ }
+
def operation_not_found(self):
return {
"message_type": "error",
@@ -65,6 +71,9 @@ def record_not_found(self):
"message": _("This record you were working on does not exist anymore."),
}
+ def barcode_not_found(self):
+ return {"message_type": "error", "message": _("Barcode not found")}
+
def operation_has_been_canceled_elsewhere(self):
return {
"message_type": "warning",
@@ -106,12 +115,28 @@ def no_pack_in_location(self, location):
def several_packs_in_location(self, location):
return {
- "message_type": "error",
+ "message_type": "warning",
"message": _(
"Several packages found in %s, please scan a package." % location.name
),
}
+ def several_lots_in_location(self, location):
+ return {
+ "message_type": "warning",
+ "message": _(
+ "Several lots found in %s, please scan a lot." % location.name
+ ),
+ }
+
+ def several_products_in_location(self, location):
+ return {
+ "message_type": "warning",
+ "message": _(
+ "Several products found in %s, please scan a product." % location.name
+ ),
+ }
+
def no_pending_operation_for_pack(self, pack):
return {
"message_type": "error",
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 65d517f896..835cbac492 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -301,7 +301,97 @@ def scan_line(self, move_line_id, barcode):
pack meanwhile (race condition).
* scan_destination: if the barcode matches.
"""
- return self._response()
+ message = self.actions_for("message")
+ move_line = self.env["stock.move.line"].browse(move_line_id)
+ if not move_line.exists():
+ # TODO go to next line? (but then handle if it's the last one)
+ return self._response(next_state="start")
+ # TODO check again the state of the move line, if already processed
+ # move to the next state or next line
+ if move_line.package_id.name == barcode:
+ return self._response_for_scan_line_ok(move_line)
+ elif move_line.product_id.barcode == barcode:
+ if move_line.product_id.tracking in ("lot", "serial"):
+ return self._response_for_scan_line_product_need_lot(move_line)
+ return self._response_for_scan_line_ok(move_line)
+ elif move_line.lot_id.name == barcode:
+ return self._response_for_scan_line_ok(move_line)
+ elif move_line.location_id.barcode == barcode:
+ # When a user scan a location, we accept only when we knows that
+ # they scanned the good thing, so if in the location we have
+ # several lots (on a package or a product), several packages,
+ # several products or a mix of several products and packages, we
+ # ask to scan a more precise barcode.
+ location = move_line.location_id
+ packages = set()
+ products = set()
+ lots = set()
+ for quant in location.quant_ids:
+ if quant.quantity <= 0:
+ continue
+ if quant.package_id:
+ packages.add(quant.package_id)
+ else:
+ products.add(quant.product_id)
+ if quant.lot_id:
+ lots.add(quant.lot_id)
+
+ if len(lots) > 1:
+ return self._response_for_scan_line_several_lots_in_loc(move_line)
+ if len(packages | products) > 1:
+ if move_line.package_id:
+ return self._response_for_scan_line_several_packages_in_loc(
+ move_line
+ )
+ else:
+ return self._response_for_scan_line_several_products_in_loc(
+ move_line
+ )
+
+ return self._response_for_scan_line_ok(move_line)
+
+ return self._response(
+ next_state="start_line",
+ data=self._data_for_next_move_line(move_line),
+ message=message.barcode_not_found(),
+ )
+
+ def _response_for_scan_line_several_lots_in_loc(self, move_line):
+ message = self.actions_for("message")
+ return self._response(
+ next_state="start_line",
+ data=self._data_for_next_move_line(move_line),
+ message=message.several_lots_in_location(move_line.location_id),
+ )
+
+ def _response_for_scan_line_several_products_in_loc(self, move_line):
+ message = self.actions_for("message")
+ return self._response(
+ next_state="start_line",
+ data=self._data_for_next_move_line(move_line),
+ message=message.several_products_in_location(move_line.location_id),
+ )
+
+ def _response_for_scan_line_several_packages_in_loc(self, move_line):
+ message = self.actions_for("message")
+ return self._response(
+ next_state="start_line",
+ data=self._data_for_next_move_line(move_line),
+ message=message.several_packs_in_location(move_line.location_id),
+ )
+
+ def _response_for_scan_line_product_need_lot(self, move_line):
+ message = self.actions_for("message")
+ return self._response(
+ next_state="start_line",
+ data=self._data_for_next_move_line(move_line),
+ message=message.scan_lot_on_product_tracked_by_lot(),
+ )
+
+ def _response_for_scan_line_ok(self, move_line):
+ return self._response(
+ next_state="scan_destination", data=self._data_for_next_move_line(move_line)
+ )
def scan_destination_pack(self, move_line_id, barcode, quantity):
"""Scan the destination package (bin) for a move line
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 99bea23eb8..8f4a5e5925 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -91,18 +91,32 @@ def assert_response(self, response, next_state=None, message=None, data=None):
)
@classmethod
- def _update_qty_in_location(cls, location, product, quantity):
- cls.env["stock.quant"]._update_available_quantity(product, location, quantity)
+ def _update_qty_in_location(
+ cls, location, product, quantity, package=None, lot=None
+ ):
+ cls.env["stock.quant"]._update_available_quantity(
+ product, location, quantity, package_id=package, lot_id=lot
+ )
@classmethod
- def _fill_stock_for_pickings(cls, pickings):
+ def _fill_stock_for_moves(cls, moves, in_package=False, in_lot=False):
product_locations = {}
- for move in pickings.mapped("move_lines"):
+ package = None
+ if in_package:
+ package = cls.env["stock.quant.package"].create({})
+ for move in moves:
key = (move.product_id, move.location_id)
product_locations.setdefault(key, 0)
product_locations[key] += move.product_qty
for (product, location), qty in product_locations.items():
- cls._update_qty_in_location(location, product, qty)
+ lot = None
+ if in_lot:
+ lot = cls.env["stock.production.lot"].create(
+ {"product_id": product.id, "company_id": cls.env.company.id}
+ )
+ cls._update_qty_in_location(
+ location, product, qty, package=package, lot=lot
+ )
class PickingBatchMixin:
@@ -142,9 +156,3 @@ def _create_picking_batch(cls, products):
batch.picking_ids.action_confirm()
batch.picking_ids.action_assign()
return batch
-
- @classmethod
- def _add_stock_and_assign_pickings_for_batches(cls, batches):
- pickings = batches.mapped("picking_ids")
- cls._fill_stock_for_pickings(pickings)
- pickings.action_assign()
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 061f316edc..091a8aa2a9 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -8,10 +8,20 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin):
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
cls.product_a = cls.env["product.product"].create(
- {"name": "Product A", "type": "product", "default_code": "A"}
+ {
+ "name": "Product A",
+ "type": "product",
+ "default_code": "A",
+ "barcode": "A",
+ }
)
cls.product_b = cls.env["product.product"].create(
- {"name": "Product B", "type": "product", "default_code": "B"}
+ {
+ "name": "Product B",
+ "type": "product",
+ "default_code": "B",
+ "barcode": "B",
+ }
)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
cls.process = cls.menu.process_id
@@ -25,8 +35,21 @@ def setUp(self):
self.service = work.component(usage="cluster_picking")
@classmethod
- def _simulate_batch_selected(cls, batches):
- cls._add_stock_and_assign_pickings_for_batches(batches)
+ def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False):
+ """Create a state as if a batch was selected by the user
+
+ * The picking batch is in progress
+ * It is assigned to the current user
+ * All the move lines are available
+
+ Note: currently, this method create a source package that contains
+ all the products of the batch. It is enough for the current tests.
+ """
+ pickings = batches.mapped("picking_ids")
+ cls._fill_stock_for_moves(
+ pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot
+ )
+ pickings.action_assign()
batches.write({"state": "in_progress", "user_id": cls.env.uid})
@@ -50,7 +73,7 @@ class ClusterPickingSelectionCase(ClusterPickingCommonCase):
These endpoints interact with a list of picking batches.
The other endpoints that interact with a single batch (after selection)
- are handled in ``ClusterPickingSelectedCase``.
+ are handled in other classes.
"""
@classmethod
@@ -71,6 +94,11 @@ def setUpClass(cls, *args, **kwargs):
[[cls.BatchProduct(product=cls.product_a, quantity=1)]]
)
+ def _add_stock_and_assign_pickings_for_batches(self, batches):
+ pickings = batches.mapped("picking_ids")
+ self._fill_stock_for_moves(pickings.mapped("move_lines"))
+ pickings.action_assign()
+
def test_find_batch_in_progress_current_user(self):
"""Find an in-progress batch assigned to the current user"""
# Simulate the client asking a batch by clicking on "get work"
@@ -377,11 +405,10 @@ class ClusterPickingSelectedCase(ClusterPickingCommonCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
- # TODO add several lines / different products
cls.batch = cls._create_picking_batch(
[[cls.BatchProduct(product=cls.product_a, quantity=1)]]
)
- cls._simulate_batch_selected(cls.batch)
+ cls._simulate_batch_selected(cls.batch, in_package=True)
def test_confirm_start_ok(self):
"""User confirms she starts the selected picking batch (happy path)"""
@@ -391,6 +418,10 @@ def test_confirm_start_ok(self):
picking = batch.picking_ids[0]
first_move_line = picking.move_line_ids[0]
self.assertTrue(first_move_line)
+ # A package exists on the move line, because the quant created
+ # by ``_simulate_batch_selected`` has a package.
+ package = first_move_line.package_id
+ self.assertTrue(package)
response = self.service.dispatch(
"confirm_start", params={"picking_batch_id": self.batch.id}
@@ -423,7 +454,7 @@ def test_confirm_start_ok(self):
"qty_available": first_move_line.product_id.qty_available,
},
"lot": None,
- "pack": None,
+ "pack": {"id": package.id, "name": package.name},
},
next_state="start_line",
)
@@ -452,3 +483,273 @@ def test_confirm_start_all_is_done(self):
# to process anymore, but we want to set pickings and
# picking batch to done if not done yet (because the process
# was interrupted for instance)
+
+
+class ClusterPickingScanLineCase(ClusterPickingCommonCase):
+ """Tests covering the /scan_line endpoint
+
+ After a batch has been selected and the user confirmed they are
+ working on it.
+
+ User scans something and the scan_line endpoints validates they
+ scanned the proper thing to pick.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # quants already existing are from demo data
+ cls.env["stock.quant"].search(
+ [("location_id", "=", cls.stock_location.id)]
+ ).unlink()
+ cls.batch = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+
+ def _line_data(self, move_line):
+ picking = move_line.picking_id
+ batch = picking.batch_id
+ # A package exists on the move line, because the quant created
+ # by ``_simulate_batch_selected`` has a package.
+ package = move_line.package_id
+ lot = move_line.lot_id
+ return {
+ "id": move_line.id,
+ "quantity": 1.0,
+ "location_dst": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "note": "",
+ "origin": picking.origin,
+ },
+ "batch": {"id": batch.id, "name": batch.name},
+ "product": {
+ "default_code": move_line.product_id.default_code,
+ "display_name": move_line.product_id.display_name,
+ "id": move_line.product_id.id,
+ "name": move_line.product_id.name,
+ "qty_available": move_line.product_id.qty_available,
+ },
+ "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""}
+ if lot
+ else None,
+ "pack": {"id": package.id, "name": package.name} if package else None,
+ }
+
+ def test_scan_line_pack_ok(self):
+ """Scan to check if user picks the correct pack for current line"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ # we only have one line in this test case
+ selected_line = self.batch.picking_ids.move_line_ids
+ package = selected_line.package_id
+ response = self.service.dispatch(
+ "scan_line",
+ params={"move_line_id": selected_line.id, "barcode": package.name},
+ )
+ self.assert_response(
+ response, next_state="scan_destination", data=self._line_data(selected_line)
+ )
+
+ def test_scan_line_product_ok(self):
+ """Scan to check if user picks the correct product for current line"""
+ self._simulate_batch_selected(self.batch)
+ # we only have one line in this test case
+ selected_line = self.batch.picking_ids.move_line_ids
+ product = selected_line.product_id
+ response = self.service.dispatch(
+ "scan_line",
+ params={"move_line_id": selected_line.id, "barcode": product.barcode},
+ )
+ self.assert_response(
+ response, next_state="scan_destination", data=self._line_data(selected_line)
+ )
+
+ def _scan_line_serial_or_lot_ok(self, tracking):
+ self.product_a.tracking = tracking
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ # we only have one line in this test case
+ selected_line = self.batch.picking_ids.move_line_ids
+ lot = selected_line.lot_id
+ response = self.service.dispatch(
+ "scan_line", params={"move_line_id": selected_line.id, "barcode": lot.name}
+ )
+ self.assert_response(
+ response, next_state="scan_destination", data=self._line_data(selected_line)
+ )
+
+ def test_scan_line_lot_ok(self):
+ """Scan to check if user picks the correct lot for current line"""
+ self._scan_line_serial_or_lot_ok("lot")
+
+ def test_scan_line_serial_ok(self):
+ """Scan to check if user picks the correct serial for current line"""
+ self._scan_line_serial_or_lot_ok("serial")
+
+ def test_scan_line_error_product_tracked(self):
+ """Scan a product tracked by lot, must scan the lot"""
+ self.product_a.tracking = "lot"
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ # we only have one line in this test case
+ selected_line = self.batch.picking_ids.move_line_ids
+ product = selected_line.product_id
+ response = self.service.dispatch(
+ "scan_line",
+ params={"move_line_id": selected_line.id, "barcode": product.barcode},
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(selected_line),
+ message={
+ "message_type": "warning",
+ "message": "Product tracked by lot, please scan one.",
+ },
+ )
+
+ def _scan_line_location_ok(self):
+ # we only have one line in this test case
+ selected_line = self.batch.picking_ids.move_line_ids
+ location = selected_line.location_id
+ response = self.service.dispatch(
+ "scan_line",
+ params={"move_line_id": selected_line.id, "barcode": location.barcode},
+ )
+ self.assert_response(
+ response, next_state="scan_destination", data=self._line_data(selected_line)
+ )
+
+ def test_scan_line_location_ok_single_package(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there is only one single package in the location, there is no
+ ambiguity so we can use it.
+ """
+ self._simulate_batch_selected(self.batch, in_package=True)
+ self._scan_line_location_ok()
+
+ def test_scan_line_location_ok_single_product(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there is only one single product in the location, there is no
+ ambiguity so we can use it.
+ """
+ self._simulate_batch_selected(self.batch)
+ self._scan_line_location_ok()
+
+ def test_scan_line_location_ok_single_lot(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there is only one single lot in the location, there is no
+ ambiguity so we can use it.
+ """
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ self._scan_line_location_ok()
+
+ def test_scan_line_location_error_several_package(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there are several packages in the location, user has to scan one.
+ """
+ self._simulate_batch_selected(self.batch, in_package=True)
+ # we only have one line in this test case
+ selected_line = self.batch.picking_ids.move_line_ids
+ location = selected_line.location_id
+
+ # add a second package in the location
+ self._update_qty_in_location(
+ location,
+ self.product_b,
+ 10,
+ package=self.env["stock.quant.package"].create({}),
+ )
+ response = self.service.dispatch(
+ "scan_line",
+ params={"move_line_id": selected_line.id, "barcode": location.barcode},
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(selected_line),
+ message={
+ "message_type": "warning",
+ "message": "Several packages found in Stock, please scan a package.",
+ },
+ )
+
+ def test_scan_line_location_error_several_products(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there are several products in the location, user has to scan one.
+ """
+ self._simulate_batch_selected(self.batch)
+ # we only have one line in this test case
+ selected_line = self.batch.picking_ids.move_line_ids
+ location = selected_line.location_id
+
+ # add a second product in the location
+ self._update_qty_in_location(location, self.product_b, 10)
+ response = self.service.dispatch(
+ "scan_line",
+ params={"move_line_id": selected_line.id, "barcode": location.barcode},
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(selected_line),
+ message={
+ "message_type": "warning",
+ "message": "Several products found in Stock, please scan a product.",
+ },
+ )
+
+ def test_scan_line_location_error_several_lots(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there are several lots in the location, user has to scan one.
+ """
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ # we only have one line in this test case
+ selected_line = self.batch.picking_ids.move_line_ids
+ location = selected_line.location_id
+ lot = self.env["stock.production.lot"].create(
+ {"product_id": self.product_a.id, "company_id": self.env.company.id}
+ )
+ # add a second lot in the location
+ self._update_qty_in_location(location, self.product_a, 10, lot=lot)
+ response = self.service.dispatch(
+ "scan_line",
+ params={"move_line_id": selected_line.id, "barcode": location.barcode},
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(selected_line),
+ message={
+ "message_type": "warning",
+ "message": "Several lots found in Stock, please scan a lot.",
+ },
+ )
+
+ def test_scan_line_error_not_found(self):
+ """Nothing found for the barcode"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ # we only have one line in this test case
+ selected_line = self.batch.picking_ids.move_line_ids
+ response = self.service.dispatch(
+ "scan_line",
+ params={"move_line_id": selected_line.id, "barcode": "NO_EXISTING_BARCODE"},
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(selected_line),
+ message={"message_type": "error", "message": "Barcode not found"},
+ )
diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py
index b40aa8726d..2be2ab16c8 100644
--- a/shopfloor/tests/test_picking_batch.py
+++ b/shopfloor/tests/test_picking_batch.py
@@ -58,7 +58,7 @@ def test_search_empty(self):
def test_search(self):
"""Return only draft batches with assigned pickings """
pickings = self.all_batches.mapped("picking_ids")
- self._fill_stock_for_pickings(pickings)
+ self._fill_stock_for_moves(pickings.mapped("move_lines"))
pickings.action_assign()
self.assertTrue(all(p.state == "assigned" for p in pickings))
# we should not have done batches in list
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 7679bef1b7..e2d57047d3 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -256,7 +256,7 @@ def test_start_pack_from_location_several_packs(self):
response,
next_state="start",
message={
- "message_type": "error",
+ "message_type": "warning",
"message": "Several packages found in %s, please scan a package."
% (self.shelf1.name,),
},
From aff1515ee8cde33ba46985e87a48707f5a774254 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 21 Feb 2020 12:24:09 +0100
Subject: [PATCH 102/940] cluster_picking: refactor scan_line tests
---
shopfloor/tests/test_cluster_picking.py | 210 ++++++++++++------------
1 file changed, 102 insertions(+), 108 deletions(-)
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 091a8aa2a9..b060439b8d 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -52,6 +52,44 @@ def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False):
pickings.action_assign()
batches.write({"state": "in_progress", "user_id": cls.env.uid})
+ def _line_data(self, move_line):
+ picking = move_line.picking_id
+ batch = picking.batch_id
+ # A package exists on the move line, because the quant created
+ # by ``_simulate_batch_selected`` has a package.
+ package = move_line.package_id
+ lot = move_line.lot_id
+ return {
+ "id": move_line.id,
+ "quantity": move_line.product_uom_qty,
+ "location_dst": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "note": "",
+ "origin": picking.origin,
+ },
+ "batch": {"id": batch.id, "name": batch.name},
+ "product": {
+ "default_code": move_line.product_id.default_code,
+ "display_name": move_line.product_id.display_name,
+ "id": move_line.product_id.id,
+ "name": move_line.product_id.name,
+ "qty_available": move_line.product_id.qty_available,
+ },
+ "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""}
+ if lot
+ else None,
+ "pack": {"id": package.id, "name": package.name} if package else None,
+ }
+
class ClusterPickingAPICase(ClusterPickingCommonCase):
"""Base tests for the cluster picking API"""
@@ -544,88 +582,65 @@ def _line_data(self, move_line):
"pack": {"id": package.id, "name": package.name} if package else None,
}
- def test_scan_line_pack_ok(self):
- """Scan to check if user picks the correct pack for current line"""
- self._simulate_batch_selected(self.batch, in_package=True)
- # we only have one line in this test case
- selected_line = self.batch.picking_ids.move_line_ids
- package = selected_line.package_id
+ def _scan_line_ok(self, line, scanned):
response = self.service.dispatch(
- "scan_line",
- params={"move_line_id": selected_line.id, "barcode": package.name},
+ "scan_line", params={"move_line_id": line.id, "barcode": scanned}
)
self.assert_response(
- response, next_state="scan_destination", data=self._line_data(selected_line)
+ response, next_state="scan_destination", data=self._line_data(line)
)
- def test_scan_line_product_ok(self):
- """Scan to check if user picks the correct product for current line"""
- self._simulate_batch_selected(self.batch)
- # we only have one line in this test case
- selected_line = self.batch.picking_ids.move_line_ids
- product = selected_line.product_id
+ def _scan_line_error(self, line, scanned, message):
response = self.service.dispatch(
- "scan_line",
- params={"move_line_id": selected_line.id, "barcode": product.barcode},
+ "scan_line", params={"move_line_id": line.id, "barcode": scanned}
)
self.assert_response(
- response, next_state="scan_destination", data=self._line_data(selected_line)
+ response,
+ next_state="start_line",
+ data=self._line_data(line),
+ message=message,
)
- def _scan_line_serial_or_lot_ok(self, tracking):
- self.product_a.tracking = tracking
- self._simulate_batch_selected(self.batch, in_lot=True)
- # we only have one line in this test case
- selected_line = self.batch.picking_ids.move_line_ids
- lot = selected_line.lot_id
- response = self.service.dispatch(
- "scan_line", params={"move_line_id": selected_line.id, "barcode": lot.name}
- )
- self.assert_response(
- response, next_state="scan_destination", data=self._line_data(selected_line)
- )
+ def test_scan_line_pack_ok(self):
+ """Scan to check if user picks the correct pack for current line"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.package_id.name)
+
+ def test_scan_line_product_ok(self):
+ """Scan to check if user picks the correct product for current line"""
+ self._simulate_batch_selected(self.batch)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.product_id.barcode)
def test_scan_line_lot_ok(self):
"""Scan to check if user picks the correct lot for current line"""
- self._scan_line_serial_or_lot_ok("lot")
+ self.product_a.tracking = "lot"
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.lot_id.name)
def test_scan_line_serial_ok(self):
"""Scan to check if user picks the correct serial for current line"""
- self._scan_line_serial_or_lot_ok("serial")
+ self.product_a.tracking = "serial"
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.lot_id.name)
def test_scan_line_error_product_tracked(self):
"""Scan a product tracked by lot, must scan the lot"""
self.product_a.tracking = "lot"
self._simulate_batch_selected(self.batch, in_lot=True)
- # we only have one line in this test case
- selected_line = self.batch.picking_ids.move_line_ids
- product = selected_line.product_id
- response = self.service.dispatch(
- "scan_line",
- params={"move_line_id": selected_line.id, "barcode": product.barcode},
- )
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(selected_line),
- message={
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_error(
+ line,
+ line.product_id.barcode,
+ {
"message_type": "warning",
"message": "Product tracked by lot, please scan one.",
},
)
- def _scan_line_location_ok(self):
- # we only have one line in this test case
- selected_line = self.batch.picking_ids.move_line_ids
- location = selected_line.location_id
- response = self.service.dispatch(
- "scan_line",
- params={"move_line_id": selected_line.id, "barcode": location.barcode},
- )
- self.assert_response(
- response, next_state="scan_destination", data=self._line_data(selected_line)
- )
-
def test_scan_line_location_ok_single_package(self):
"""Scan to check if user scans a correct location for current line
@@ -633,7 +648,8 @@ def test_scan_line_location_ok_single_package(self):
ambiguity so we can use it.
"""
self._simulate_batch_selected(self.batch, in_package=True)
- self._scan_line_location_ok()
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.location_id.barcode)
def test_scan_line_location_ok_single_product(self):
"""Scan to check if user scans a correct location for current line
@@ -642,7 +658,8 @@ def test_scan_line_location_ok_single_product(self):
ambiguity so we can use it.
"""
self._simulate_batch_selected(self.batch)
- self._scan_line_location_ok()
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.location_id.barcode)
def test_scan_line_location_ok_single_lot(self):
"""Scan to check if user scans a correct location for current line
@@ -651,7 +668,8 @@ def test_scan_line_location_ok_single_lot(self):
ambiguity so we can use it.
"""
self._simulate_batch_selected(self.batch, in_lot=True)
- self._scan_line_location_ok()
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.location_id.barcode)
def test_scan_line_location_error_several_package(self):
"""Scan to check if user scans a correct location for current line
@@ -659,10 +677,8 @@ def test_scan_line_location_error_several_package(self):
If there are several packages in the location, user has to scan one.
"""
self._simulate_batch_selected(self.batch, in_package=True)
- # we only have one line in this test case
- selected_line = self.batch.picking_ids.move_line_ids
- location = selected_line.location_id
-
+ line = self.batch.picking_ids.move_line_ids
+ location = line.location_id
# add a second package in the location
self._update_qty_in_location(
location,
@@ -670,15 +686,10 @@ def test_scan_line_location_error_several_package(self):
10,
package=self.env["stock.quant.package"].create({}),
)
- response = self.service.dispatch(
- "scan_line",
- params={"move_line_id": selected_line.id, "barcode": location.barcode},
- )
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(selected_line),
- message={
+ self._scan_line_error(
+ line,
+ location.barcode,
+ {
"message_type": "warning",
"message": "Several packages found in Stock, please scan a package.",
},
@@ -690,21 +701,14 @@ def test_scan_line_location_error_several_products(self):
If there are several products in the location, user has to scan one.
"""
self._simulate_batch_selected(self.batch)
- # we only have one line in this test case
- selected_line = self.batch.picking_ids.move_line_ids
- location = selected_line.location_id
-
+ line = self.batch.picking_ids.move_line_ids
+ location = line.location_id
# add a second product in the location
self._update_qty_in_location(location, self.product_b, 10)
- response = self.service.dispatch(
- "scan_line",
- params={"move_line_id": selected_line.id, "barcode": location.barcode},
- )
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(selected_line),
- message={
+ self._scan_line_error(
+ line,
+ location.barcode,
+ {
"message_type": "warning",
"message": "Several products found in Stock, please scan a product.",
},
@@ -716,23 +720,17 @@ def test_scan_line_location_error_several_lots(self):
If there are several lots in the location, user has to scan one.
"""
self._simulate_batch_selected(self.batch, in_lot=True)
- # we only have one line in this test case
- selected_line = self.batch.picking_ids.move_line_ids
- location = selected_line.location_id
+ line = self.batch.picking_ids.move_line_ids
+ location = line.location_id
lot = self.env["stock.production.lot"].create(
{"product_id": self.product_a.id, "company_id": self.env.company.id}
)
# add a second lot in the location
self._update_qty_in_location(location, self.product_a, 10, lot=lot)
- response = self.service.dispatch(
- "scan_line",
- params={"move_line_id": selected_line.id, "barcode": location.barcode},
- )
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(selected_line),
- message={
+ self._scan_line_error(
+ line,
+ location.barcode,
+ {
"message_type": "warning",
"message": "Several lots found in Stock, please scan a lot.",
},
@@ -741,15 +739,11 @@ def test_scan_line_location_error_several_lots(self):
def test_scan_line_error_not_found(self):
"""Nothing found for the barcode"""
self._simulate_batch_selected(self.batch, in_package=True)
- # we only have one line in this test case
- selected_line = self.batch.picking_ids.move_line_ids
- response = self.service.dispatch(
- "scan_line",
- params={"move_line_id": selected_line.id, "barcode": "NO_EXISTING_BARCODE"},
- )
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(selected_line),
- message={"message_type": "error", "message": "Barcode not found"},
+ self._scan_line_error(
+ self.batch.picking_ids.move_line_ids,
+ "NO_EXISTING_BARCODE",
+ {"message_type": "error", "message": "Barcode not found"},
)
+
+
+# TODO tests for transitions to next line / no next lines, ...
From 48f2ea0244f04acbcc95549d057265e6dd154d59 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 21 Feb 2020 13:05:29 +0100
Subject: [PATCH 103/940] cluster_picking: add happy path for
/scan_destination_pack
---
shopfloor/services/cluster_picking.py | 47 ++++++++++++++++++++-
shopfloor/tests/test_cluster_picking.py | 55 +++++++++++++++++++++++++
2 files changed, 101 insertions(+), 1 deletion(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 835cbac492..e0757705d3 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -413,7 +413,51 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
have the same destination.
* start_line: to pick the next line if any.
"""
- return self._response()
+ move_line = self.env["stock.move.line"].browse(move_line_id)
+ if not move_line.exists():
+ # TODO go to next line? (but then handle if it's the last one)
+ return self._response(next_state="start")
+ # TODO if another line of the picking has a destination package, handle
+ # it (note: should be added to the 'single line' schema /data as well,
+ # maybe use a computed field).
+
+ # TODO handle partial pick
+ if quantity > move_line.product_uom_qty:
+ # TODO (+ use float_tools)
+ return self._response()
+ # TODO handle destination bin not empty
+ search = self.actions_for("search")
+ bin_package = search.package_from_scan(barcode)
+ if not bin_package:
+ # TODO
+ return self._response()
+ move_line.write({"qty_done": quantity, "result_package_id": bin_package.id})
+ # TODO zero check
+ # TODO handle next line and no next line (in a shared way with other
+ # endpoints)
+ batch = move_line.picking_id.batch_id
+ remaining_lines = batch.mapped("picking_ids.move_line_ids").filtered(
+ lambda line: not line.result_package_id
+ )
+ if not remaining_lines:
+ return self._response(
+ next_state="start",
+ message={"message_type": "info", "message": "Not implemented"},
+ )
+ next_line = remaining_lines[0]
+ return self._response(
+ next_state="start_line",
+ data=self._data_for_next_move_line(next_line),
+ message={
+ "message_type": "info",
+ # TODO different message for products/packs?
+ "message": _("{} {} put in {}").format(
+ move_line.qty_done,
+ move_line.product_id.display_name,
+ bin_package.name,
+ ),
+ },
+ )
def prepare_unload(self, picking_batch_id):
"""Initiate the unloading phase of the process
@@ -962,6 +1006,7 @@ def _schema_for_single_line_details(self):
"name": {"type": "string", "nullable": False, "required": True},
},
},
+ # TODO add destination pack
"pack": {
"type": "dict",
"required": False,
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index b060439b8d..6916a94943 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -746,4 +746,59 @@ def test_scan_line_error_not_found(self):
)
+class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase):
+ """Tests covering the /scan_destination_pack endpoint
+
+ After a batch has been selected and the user confirmed they are
+ working on it, user picked the good, now they scan the location
+ destination.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.batch = cls._create_picking_batch(
+ [
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10),
+ cls.BatchProduct(product=cls.product_b, quantity=10),
+ ],
+ [cls.BatchProduct(product=cls.product_a, quantity=10)],
+ ]
+ )
+ cls.bin1 = cls.env["stock.quant.package"].create({})
+
+ def test_scan_destination_pack_ok(self):
+ """Happy path for scan destination package
+
+ It sets the line in the pack for the full qty
+ """
+ self._simulate_batch_selected(self.batch)
+ line = self.batch.picking_ids.move_line_ids[0]
+ next_line = self.batch.picking_ids.move_line_ids[1]
+ qty_done = line.product_uom_qty
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "move_line_id": line.id,
+ "barcode": self.bin1.name,
+ "quantity": qty_done,
+ },
+ )
+ self.assertRecordValues(
+ line, [{"qty_done": qty_done, "result_package_id": self.bin1.id}]
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(next_line),
+ message={
+ "message_type": "info",
+ "message": "{} {} put in {}".format(
+ line.qty_done, line.product_id.display_name, self.bin1.name
+ ),
+ },
+ )
+
+
# TODO tests for transitions to next line / no next lines, ...
From 7950def53ad39cce971079146c61d4c41cfcaa04 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 24 Feb 2020 16:14:02 +0100
Subject: [PATCH 104/940] cluster_picking: split move lines getter and common
test case
---
shopfloor/services/cluster_picking.py | 23 ++++++++++++++++-------
shopfloor/tests/test_cluster_picking.py | 25 +++++++++++++++----------
2 files changed, 31 insertions(+), 17 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index e0757705d3..430bb30a04 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -215,17 +215,28 @@ def confirm_start(self, picking_batch_id):
if not picking_batch.exists():
return self._response_batch_does_not_exist()
- remaining_lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(
- lambda l: l.state == "assigned"
- )
+ remaining_lines = self._assigned_lines_for_picking_batch(picking_batch)
if not remaining_lines:
# TODO
pass
-
return self._response(
next_state="start_line", data=self._data_for_next_move_line(remaining_lines)
)
+ def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
+ lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func)
+ return lines
+
+ def _assigned_lines_for_picking_batch(self, picking_batch):
+ return self._lines_for_picking_batch(
+ picking_batch, filter_func=lambda l: l.state == 'assigned'
+ )
+
+ def _unpackaged_lines_for_picking_batch(self, picking_batch):
+ return self._lines_for_picking_batch(
+ picking_batch, filter_func=lambda l: not l.result_package_id
+ )
+
def _response_batch_does_not_exist(self):
message = self.actions_for("message")
return self._response(next_state="start", message=message.record_not_found())
@@ -436,9 +447,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
# TODO handle next line and no next line (in a shared way with other
# endpoints)
batch = move_line.picking_id.batch_id
- remaining_lines = batch.mapped("picking_ids.move_line_ids").filtered(
- lambda line: not line.result_package_id
- )
+ remaining_lines = self._unpackaged_lines_for_picking_batch(batch)
if not remaining_lines:
return self._response(
next_state="start",
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 6916a94943..287f0ae50c 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -523,15 +523,7 @@ def test_confirm_start_all_is_done(self):
# was interrupted for instance)
-class ClusterPickingScanLineCase(ClusterPickingCommonCase):
- """Tests covering the /scan_line endpoint
-
- After a batch has been selected and the user confirmed they are
- working on it.
-
- User scans something and the scan_line endpoints validates they
- scanned the proper thing to pick.
- """
+class ClusterPickingLineCommonCase(ClusterPickingCommonCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
@@ -582,6 +574,17 @@ def _line_data(self, move_line):
"pack": {"id": package.id, "name": package.name} if package else None,
}
+
+class ClusterPickingScanLineCase(ClusterPickingLineCommonCase):
+ """Tests covering the /scan_line endpoint
+
+ After a batch has been selected and the user confirmed they are
+ working on it.
+
+ User scans something and the scan_line endpoints validates they
+ scanned the proper thing to pick.
+ """
+
def _scan_line_ok(self, line, scanned):
response = self.service.dispatch(
"scan_line", params={"move_line_id": line.id, "barcode": scanned}
@@ -763,7 +766,9 @@ def setUpClass(cls, *args, **kwargs):
cls.BatchProduct(product=cls.product_a, quantity=10),
cls.BatchProduct(product=cls.product_b, quantity=10),
],
- [cls.BatchProduct(product=cls.product_a, quantity=10)],
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10)
+ ],
]
)
cls.bin1 = cls.env["stock.quant.package"].create({})
From dfe1089100795b6bf9159b2000923b8b5898b81b Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 24 Feb 2020 16:41:12 +0100
Subject: [PATCH 105/940] cluster_picking: add /prepare_unload happy paths
---
shopfloor/models/__init__.py | 1 +
shopfloor/models/stock_move_line.py | 7 ++
shopfloor/services/cluster_picking.py | 48 ++++++++++-
shopfloor/tests/test_cluster_picking.py | 103 +++++++++++++++++++++++-
4 files changed, 153 insertions(+), 6 deletions(-)
create mode 100644 shopfloor/models/stock_move_line.py
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index b39ee610aa..1cc7ee589a 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -4,5 +4,6 @@
from . import stock_picking_type
from . import shopfloor_profile
from . import stock_location
+from . import stock_move_line
from . import stock_picking_batch
from . import res_users
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
new file mode 100644
index 0000000000..d3b67bab63
--- /dev/null
+++ b/shopfloor/models/stock_move_line.py
@@ -0,0 +1,7 @@
+from odoo import fields, models
+
+
+class StockMoveLine(models.Model):
+ _inherit = "stock.move.line"
+
+ shopfloor_unloaded = fields.Boolean(default=False)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 430bb30a04..9cb9b2804a 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -229,7 +229,7 @@ def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
def _assigned_lines_for_picking_batch(self, picking_batch):
return self._lines_for_picking_batch(
- picking_batch, filter_func=lambda l: l.state == 'assigned'
+ picking_batch, filter_func=lambda l: l.state == "assigned"
)
def _unpackaged_lines_for_picking_batch(self, picking_batch):
@@ -481,7 +481,51 @@ def prepare_unload(self, picking_batch_id):
* unload_all: when ``cluster_picking_unload_all`` is True
* unload_single: when ``cluster_picking_unload_all`` is False
"""
- return self._response()
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
+ return self._response_batch_does_not_exist()
+ if len(batch.mapped("picking_ids.move_line_ids.location_dest_id")) == 1:
+ batch.cluster_picking_unload_all = True
+ return self._response_for_unload_all(batch)
+ else:
+ # the lines have different destinations
+ batch.cluster_picking_unload_all = False
+ return self._response_for_unload_single(batch)
+
+ def _data_for_unload(self, move_line):
+ batch = move_line.picking_id.batch_id
+ return {
+ "id": batch.id,
+ "name": batch.name,
+ "location_dst": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ }
+
+ def _response_for_unload_all(self, batch):
+ # all the lines destinations are the same here
+ first_line = batch.mapped("picking_ids.move_line_ids")[0]
+ return self._response(
+ next_state="unload_all", data=self._data_for_unload(first_line)
+ )
+
+ def _next_line_for_unload_single(self, batch):
+ lines = batch.mapped("picking_ids.move_line_ids")
+ for line in lines:
+ if line.shopfloor_unloaded:
+ continue
+ return line
+ return self.env["stock.move.line"].browse()
+
+ def _response_for_unload_single(self, batch):
+ next_line = self._next_line_for_unload_single(batch)
+ if not next_line:
+ # TODO ensure batch is 'done' and go to start?
+ return self._response()
+ return self._response(
+ next_state="unload_single", data=self._data_for_unload(next_line)
+ )
def is_zero(self, move_line_id, zero):
"""Confirm or not if the source location of a move has zero qty
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 287f0ae50c..d6267314aa 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -524,7 +524,6 @@ def test_confirm_start_all_is_done(self):
class ClusterPickingLineCommonCase(ClusterPickingCommonCase):
-
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
@@ -766,9 +765,7 @@ def setUpClass(cls, *args, **kwargs):
cls.BatchProduct(product=cls.product_a, quantity=10),
cls.BatchProduct(product=cls.product_b, quantity=10),
],
- [
- cls.BatchProduct(product=cls.product_a, quantity=10)
- ],
+ [cls.BatchProduct(product=cls.product_a, quantity=10)],
]
)
cls.bin1 = cls.env["stock.quant.package"].create({})
@@ -806,4 +803,102 @@ def test_scan_destination_pack_ok(self):
)
+class ClusterPickingPrepareUnloadPackCase(ClusterPickingCommonCase):
+ """Tests covering the /prepare_unload endpoint
+
+ Destination packages have been set on all the move lines of the batch.
+ The unload operation will start, but we have 2 paths for this:
+
+ 1. unload all the destination packages at the same place
+ 2. unload the destination packages one by one at different places
+
+ By default, if all the move lines have the same destination, the
+ first path is used. A flag on the batch picking keeps track of which
+ path is used.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.batch = cls._create_picking_batch(
+ [
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10),
+ cls.BatchProduct(product=cls.product_b, quantity=10),
+ ],
+ [cls.BatchProduct(product=cls.product_a, quantity=10)],
+ ]
+ )
+ cls._simulate_batch_selected(cls.batch)
+ cls.bin1 = cls.env["stock.quant.package"].create({})
+ cls.bin2 = cls.env["stock.quant.package"].create({})
+ cls.packing_a_location = cls.env["stock.location"].create(
+ {
+ "name": "Packing A",
+ "barcode": "Packing-A",
+ "location_id": cls.packing_location.id,
+ }
+ )
+ cls.packing_b_location = cls.env["stock.location"].create(
+ {
+ "name": "Packing B",
+ "barcode": "Packing-B",
+ "location_id": cls.packing_location.id,
+ }
+ )
+
+ def _set_dest_package_and_done(self, move_lines, dest_package):
+ """Simulate what would have been done in the previous steps"""
+ for line in move_lines:
+ line.write({"qty_done": line.qty_done, "package_id": dest_package.id})
+
+ def test_prepare_unload_all_same_dest(self):
+ """All move lines have the same destination location"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines[:1], self.bin1)
+ self._set_dest_package_and_done(move_lines[1:], self.bin2)
+ move_lines.write({"location_dest_id": self.packing_location.id})
+ response = self.service.dispatch(
+ "prepare_unload", params={"picking_batch_id": self.batch.id}
+ )
+ self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": True}])
+ self.assert_response(
+ response,
+ next_state="unload_all",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "location_dst": {
+ "id": self.packing_location.id,
+ "name": self.packing_location.name,
+ },
+ },
+ )
+
+ def test_prepare_unload_different_dest(self):
+ """All move lines have different destination locations"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines[:1], self.bin1)
+ self._set_dest_package_and_done(move_lines[1:], self.bin2)
+ move_lines[:1].write({"location_dest_id": self.packing_a_location.id})
+ move_lines[:1].write({"location_dest_id": self.packing_b_location.id})
+ response = self.service.dispatch(
+ "prepare_unload", params={"picking_batch_id": self.batch.id}
+ )
+ self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
+ first_line = move_lines[0]
+ self.assert_response(
+ response,
+ next_state="unload_single",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "location_dst": {
+ "id": first_line.location_dest_id.id,
+ "name": first_line.location_dest_id.name,
+ },
+ },
+ )
+
+
# TODO tests for transitions to next line / no next lines, ...
From db5b6fb142bd491c9a90b4aec57128a6f77baeb1 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 25 Feb 2020 09:33:09 +0100
Subject: [PATCH 106/940] backend: prepare cluster_picking for /skip_line
---
shopfloor/models/stock_move_line.py | 6 +++
shopfloor/services/cluster_picking.py | 2 +
shopfloor/tests/test_cluster_picking.py | 69 +++++++++++--------------
3 files changed, 38 insertions(+), 39 deletions(-)
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index d3b67bab63..d3e9997ac0 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -5,3 +5,9 @@ class StockMoveLine(models.Model):
_inherit = "stock.move.line"
shopfloor_unloaded = fields.Boolean(default=False)
+ shopfloor_postponed = fields.Boolean(
+ default=False,
+ copy=False,
+ help="Technical field. "
+ "Indicates if a the move has been postponed in a process.",
+ )
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 9cb9b2804a..1736ee711f 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -253,6 +253,7 @@ def _data_for_next_move_line(self, move_lines):
# for each model
"id": line.id,
"quantity": line.product_uom_qty,
+ "postponed": line.shopfloor_postponed,
"picking": {
"id": picking.id,
"name": picking.name,
@@ -996,6 +997,7 @@ def _schema_for_single_line_details(self):
# id is a stock.move.line
"id": {"required": True, "type": "integer"},
"quantity": {"type": "float", "required": True},
+ "postponed": {"type": "boolean", "required": False},
"picking": {
"type": "dict",
"schema": {
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index d6267314aa..74879c0b30 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -52,7 +52,7 @@ def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False):
pickings.action_assign()
batches.write({"state": "in_progress", "user_id": cls.env.uid})
- def _line_data(self, move_line):
+ def _line_data(self, move_line, qty=None):
picking = move_line.picking_id
batch = picking.batch_id
# A package exists on the move line, because the quant created
@@ -61,7 +61,8 @@ def _line_data(self, move_line):
lot = move_line.lot_id
return {
"id": move_line.id,
- "quantity": move_line.product_uom_qty,
+ "quantity": qty or move_line.product_uom_qty,
+ "postponed": move_line.shopfloor_postponed,
"location_dst": {
"id": move_line.location_dest_id.id,
"name": move_line.location_dest_id.name,
@@ -469,6 +470,7 @@ def test_confirm_start_ok(self):
data={
"id": first_move_line.id,
"quantity": 1.0,
+ "postponed": False,
"location_dst": {
"id": first_move_line.location_dest_id.id,
"name": first_move_line.location_dest_id.name,
@@ -535,43 +537,9 @@ def setUpClass(cls, *args, **kwargs):
[[cls.BatchProduct(product=cls.product_a, quantity=1)]]
)
- def _line_data(self, move_line):
- picking = move_line.picking_id
- batch = picking.batch_id
- # A package exists on the move line, because the quant created
- # by ``_simulate_batch_selected`` has a package.
- package = move_line.package_id
- lot = move_line.lot_id
- return {
- "id": move_line.id,
- "quantity": 1.0,
- "location_dst": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "picking": {
- "id": picking.id,
- "name": picking.name,
- "note": "",
- "origin": picking.origin,
- },
- "batch": {"id": batch.id, "name": batch.name},
- "product": {
- "default_code": move_line.product_id.default_code,
- "display_name": move_line.product_id.display_name,
- "id": move_line.product_id.id,
- "name": move_line.product_id.name,
- "qty_available": move_line.product_id.qty_available,
- },
- "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""}
- if lot
- else None,
- "pack": {"id": package.id, "name": package.name} if package else None,
- }
+ def _line_data(self, move_line, qty=1.0):
+ # just force qty to 1.0
+ return super()._line_data(move_line, qty=qty)
class ClusterPickingScanLineCase(ClusterPickingLineCommonCase):
@@ -899,6 +867,29 @@ def test_prepare_unload_different_dest(self):
},
},
)
+# TODO
+# class ClusterPickingSkipLineCase(ClusterPickingLineCommonCase):
+# """Tests covering the /skip_line endpoint
+# """
+
+# def _skip_line(self, line, scanned):
+# response = self.service.dispatch(
+# "scan_line", params={"move_line_id": line.id, "barcode": scanned}
+# )
+# self.assert_response(
+# response, next_state="scan_destination", data=self._line_data(line)
+# )
+
+# @classmethod
+# def setUpClass(cls, *args, **kwargs):
+# super().setUpClass(*args, **kwargs)
+# # quants already existing are from demo data
+# cls.env["stock.quant"].search(
+# [("location_id", "=", cls.stock_location.id)]
+# ).unlink()
+# cls.batch = cls._create_picking_batch(
+# [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+# )
# TODO tests for transitions to next line / no next lines, ...
From 28a5140a2882d0725b1a26e829ac9e7f3bc24c53 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 25 Feb 2020 09:48:12 +0100
Subject: [PATCH 107/940] cluster_picking: order by postponed state, add method
for first line
---
shopfloor/services/cluster_picking.py | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 1736ee711f..e2967065e6 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1,4 +1,4 @@
-from odoo import _
+from odoo import fields, _
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
@@ -225,7 +225,7 @@ def confirm_start(self, picking_batch_id):
def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func)
- return lines
+ return lines.sorted(key=lambda x: x.shopfloor_postponed)
def _assigned_lines_for_picking_batch(self, picking_batch):
return self._lines_for_picking_batch(
@@ -237,6 +237,9 @@ def _unpackaged_lines_for_picking_batch(self, picking_batch):
picking_batch, filter_func=lambda l: not l.result_package_id
)
+ def _first_line_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
+ return fields.first(self._lines_for_picking_batch(picking_batch, filter_func=filter_func))
+
def _response_batch_does_not_exist(self):
message = self.actions_for("message")
return self._response(next_state="start", message=message.record_not_found())
@@ -506,12 +509,13 @@ def _data_for_unload(self, move_line):
def _response_for_unload_all(self, batch):
# all the lines destinations are the same here
- first_line = batch.mapped("picking_ids.move_line_ids")[0]
+ first_line = self._first_line_for_picking_batch(batch)
return self._response(
next_state="unload_all", data=self._data_for_unload(first_line)
)
def _next_line_for_unload_single(self, batch):
+ # TODO: shall we use `_first_line_for_picking_batch` + lambda filter?
lines = batch.mapped("picking_ids.move_line_ids")
for line in lines:
if line.shopfloor_unloaded:
From 126a771d93799808bb4e25e4a18bcaeb27ab9a14 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 25 Feb 2020 10:45:13 +0100
Subject: [PATCH 108/940] cluster_picking: /skip_line
---
shopfloor/services/cluster_picking.py | 31 ++++++++--
shopfloor/tests/test_cluster_picking.py | 78 +++++++++++++++++--------
2 files changed, 80 insertions(+), 29 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index e2967065e6..71acc8bd03 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1,4 +1,4 @@
-from odoo import fields, _
+from odoo import _, fields
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
@@ -238,7 +238,12 @@ def _unpackaged_lines_for_picking_batch(self, picking_batch):
)
def _first_line_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
- return fields.first(self._lines_for_picking_batch(picking_batch, filter_func=filter_func))
+ return fields.first(
+ self._lines_for_picking_batch(picking_batch, filter_func=filter_func)
+ )
+
+ def _next_line_for_picking_batch(self, picking_batch):
+ return fields.first(self._unpackaged_lines_for_picking_batch(picking_batch))
def _response_batch_does_not_exist(self):
message = self.actions_for("message")
@@ -451,13 +456,12 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
# TODO handle next line and no next line (in a shared way with other
# endpoints)
batch = move_line.picking_id.batch_id
- remaining_lines = self._unpackaged_lines_for_picking_batch(batch)
- if not remaining_lines:
+ next_line = self._next_line_for_picking_batch(batch)
+ if not next_line:
return self._response(
next_state="start",
message={"message_type": "info", "message": "Not implemented"},
)
- next_line = remaining_lines[0]
return self._response(
next_state="start_line",
data=self._data_for_next_move_line(next_line),
@@ -560,7 +564,22 @@ def skip_line(self, move_line_id):
* start_line: with data for the next line (or itself if it's the last one,
in such case, a helpful message is returned)
"""
- return self._response()
+ move_line = self.env["stock.move.line"].browse(move_line_id)
+ if not move_line.exists():
+ # TODO go to next line? (but then handle if it's the last one)
+ return self._response(next_state="start")
+ # flag as postponed
+ move_line.shopfloor_postponed = True
+ return self._response_for_skip_line(move_line)
+
+ def _response_for_skip_line(self, move_line):
+ next_line = self._next_line_for_picking_batch(move_line.picking_id.batch_id)
+ if not next_line:
+ # TODO ensure batch is 'done' and go to start?
+ return self._response()
+ return self._response(
+ next_state="start_line", data=self._data_for_next_move_line(next_line)
+ )
def stock_issue(self, move_line_id):
"""Declare a stock issue for a line
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 74879c0b30..463c9951d8 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -867,29 +867,61 @@ def test_prepare_unload_different_dest(self):
},
},
)
-# TODO
-# class ClusterPickingSkipLineCase(ClusterPickingLineCommonCase):
-# """Tests covering the /skip_line endpoint
-# """
-
-# def _skip_line(self, line, scanned):
-# response = self.service.dispatch(
-# "scan_line", params={"move_line_id": line.id, "barcode": scanned}
-# )
-# self.assert_response(
-# response, next_state="scan_destination", data=self._line_data(line)
-# )
-
-# @classmethod
-# def setUpClass(cls, *args, **kwargs):
-# super().setUpClass(*args, **kwargs)
-# # quants already existing are from demo data
-# cls.env["stock.quant"].search(
-# [("location_id", "=", cls.stock_location.id)]
-# ).unlink()
-# cls.batch = cls._create_picking_batch(
-# [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
-# )
+
+
+class ClusterPickingSkipLineCase(ClusterPickingCommonCase):
+ """Tests covering the /skip_line endpoint
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # quants already existing are from demo data
+ cls.env["stock.quant"].search(
+ [("location_id", "=", cls.stock_location.id)]
+ ).unlink()
+ cls.batch = cls._create_picking_batch(
+ [
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10),
+ cls.BatchProduct(product=cls.product_b, quantity=20),
+ ],
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=30),
+ cls.BatchProduct(product=cls.product_b, quantity=40),
+ ],
+ ]
+ )
+
+ def _skip_line(self, line, next_line=None):
+ response = self.service.dispatch("skip_line", params={"move_line_id": line.id})
+ if next_line:
+ self.assert_response(
+ response, next_state="start_line", data=self._line_data(next_line)
+ )
+ return response
+
+ def test_skip_line(self):
+ self._simulate_batch_selected(self.batch, in_package=True)
+ lines = self.batch.picking_ids.move_line_ids
+ # 1st line, next is 2nd
+ self.assertFalse(lines[0].shopfloor_postponed)
+ self._skip_line(lines[0], lines[1])
+ self.assertTrue(lines[0].shopfloor_postponed)
+ # 2nd line, next is 3rd
+ self.assertFalse(lines[1].shopfloor_postponed)
+ self._skip_line(lines[1], lines[2])
+ self.assertTrue(lines[1].shopfloor_postponed)
+ # 3rd line, next is 4th
+ self.assertFalse(lines[2].shopfloor_postponed)
+ self._skip_line(lines[2], lines[3])
+ self.assertTrue(lines[2].shopfloor_postponed)
+ # 4th line, next is 1st
+ # the next line for the last one is the 1st,
+ # because you'll have to process it anyway
+ self.assertFalse(lines[3].shopfloor_postponed)
+ self._skip_line(lines[3], lines[0])
+ self.assertTrue(lines[3].shopfloor_postponed)
# TODO tests for transitions to next line / no next lines, ...
From a7c4410dd621e46f472917c858cec82e2a813497 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 25 Feb 2020 11:01:57 +0100
Subject: [PATCH 109/940] Add fake READMEs
---
shopfloor/README.rst | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
create mode 100644 shopfloor/README.rst
diff --git a/shopfloor/README.rst b/shopfloor/README.rst
new file mode 100644
index 0000000000..4adf2c2512
--- /dev/null
+++ b/shopfloor/README.rst
@@ -0,0 +1,24 @@
+**This file is going to be generated by oca-gen-addon-readme.**
+
+*Manual changes will be overwritten.*
+
+
+TODO
+
+Please provide content in the ``readme`` directory:
+
+* **DESCRIPTION.rst** (required)
+* INSTALL.rst (optional)
+* CONFIGURE.rst (optional)
+* **USAGE.rst** (optional, highly recommended)
+* DEVELOP.rst (optional)
+* ROADMAP.rst (optional)
+* HISTORY.rst (optional, recommended)
+* **CONTRIBUTORS.rst** (optional, highly recommended)
+* CREDITS.rst (optional)
+
+Content of this README will also be drawn from the addon manifest,
+from keys such as name, authors, maintainers, development_status,
+and license.
+
+A good, one sentence summary in the manifest is also highly recommended.
From b488240732091198239229f62597e5f86efd3db8 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 25 Feb 2020 11:04:01 +0100
Subject: [PATCH 110/940] backend: linting
---
shopfloor/models/stock_move_line.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index d3e9997ac0..b829cb8d85 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -9,5 +9,5 @@ class StockMoveLine(models.Model):
default=False,
copy=False,
help="Technical field. "
- "Indicates if a the move has been postponed in a process.",
+ "Indicates if a the move has been postponed in a process.",
)
From 0da3095802c0f342cf750db28d6552329973c1fa Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 25 Feb 2020 09:40:47 +0100
Subject: [PATCH 111/940] cluster_picking: add /set_destination_all happy paths
---
shopfloor/services/cluster_picking.py | 149 +++++++++----
shopfloor/tests/test_cluster_picking.py | 273 +++++++++++++++++-------
2 files changed, 307 insertions(+), 115 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 71acc8bd03..dc4ab7078d 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -112,8 +112,7 @@ def list_batch(self):
def _select_a_picking_batch(self, batches):
# look for in progress + assigned to self first
candidates = batches.filtered(
- lambda batch: batch.state == "in_progress"
- and batch.user_id == self.env.user
+ lambda batch: batch.state == "in_progress" and batch.user_id == self.env.user
)
if candidates:
return candidates[0]
@@ -215,42 +214,53 @@ def confirm_start(self, picking_batch_id):
if not picking_batch.exists():
return self._response_batch_does_not_exist()
- remaining_lines = self._assigned_lines_for_picking_batch(picking_batch)
- if not remaining_lines:
+ next_line = self._next_line_for_pick(picking_batch)
+ if not next_line:
# TODO
- pass
+ return self._response(
+ next_state="start",
+ message={
+ "message_type": "error",
+ "message": "no lines remaining, not implemented",
+ },
+ )
+
return self._response(
- next_state="start_line", data=self._data_for_next_move_line(remaining_lines)
+ next_state="start_line", data=self._data_move_line(next_line)
)
def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func)
+ # TODO we probably don't care about the postponed order in the 'set
+ # destination location' step, reset it on 'scan_destination_pack'?
return lines.sorted(key=lambda x: x.shopfloor_postponed)
- def _assigned_lines_for_picking_batch(self, picking_batch):
+ def _lines_to_pick(self, picking_batch):
return self._lines_for_picking_batch(
- picking_batch, filter_func=lambda l: l.state == "assigned"
- )
-
- def _unpackaged_lines_for_picking_batch(self, picking_batch):
- return self._lines_for_picking_batch(
- picking_batch, filter_func=lambda l: not l.result_package_id
- )
-
- def _first_line_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
- return fields.first(
- self._lines_for_picking_batch(picking_batch, filter_func=filter_func)
+ picking_batch,
+ filter_func=lambda l: (
+ l.state == "assigned"
+ # On 'StockPicking.action_assign()', result_package_id is set to
+ # the same package as 'package_id'. Here, we need to exclude lines
+ # that were already put into a bin, i.e. the destination package
+ # is different.
+ and (not l.result_package_id or l.result_package_id == l.package_id)
+ ),
)
- def _next_line_for_picking_batch(self, picking_batch):
- return fields.first(self._unpackaged_lines_for_picking_batch(picking_batch))
+ def _next_line_for_pick(self, picking_batch):
+ remaining_lines = self._lines_to_pick(picking_batch)
+ return fields.first(remaining_lines)
def _response_batch_does_not_exist(self):
message = self.actions_for("message")
return self._response(next_state="start", message=message.record_not_found())
- def _data_for_next_move_line(self, move_lines):
- line = move_lines[0]
+ def _data_for_next_move_line(self, picking_batch):
+ remaining_lines = self._lines_to_pick(picking_batch)
+ return self._data_move_line(remaining_lines[0])
+
+ def _data_move_line(self, line):
picking = line.picking_id
batch = picking.batch_id
product = line.product_id
@@ -372,7 +382,7 @@ def scan_line(self, move_line_id, barcode):
return self._response(
next_state="start_line",
- data=self._data_for_next_move_line(move_line),
+ data=self._data_move_line(move_line),
message=message.barcode_not_found(),
)
@@ -380,7 +390,7 @@ def _response_for_scan_line_several_lots_in_loc(self, move_line):
message = self.actions_for("message")
return self._response(
next_state="start_line",
- data=self._data_for_next_move_line(move_line),
+ data=self._data_move_line(move_line),
message=message.several_lots_in_location(move_line.location_id),
)
@@ -388,7 +398,7 @@ def _response_for_scan_line_several_products_in_loc(self, move_line):
message = self.actions_for("message")
return self._response(
next_state="start_line",
- data=self._data_for_next_move_line(move_line),
+ data=self._data_move_line(move_line),
message=message.several_products_in_location(move_line.location_id),
)
@@ -396,7 +406,7 @@ def _response_for_scan_line_several_packages_in_loc(self, move_line):
message = self.actions_for("message")
return self._response(
next_state="start_line",
- data=self._data_for_next_move_line(move_line),
+ data=self._data_move_line(move_line),
message=message.several_packs_in_location(move_line.location_id),
)
@@ -404,13 +414,13 @@ def _response_for_scan_line_product_need_lot(self, move_line):
message = self.actions_for("message")
return self._response(
next_state="start_line",
- data=self._data_for_next_move_line(move_line),
+ data=self._data_move_line(move_line),
message=message.scan_lot_on_product_tracked_by_lot(),
)
def _response_for_scan_line_ok(self, move_line):
return self._response(
- next_state="scan_destination", data=self._data_for_next_move_line(move_line)
+ next_state="scan_destination", data=self._data_move_line(move_line)
)
def scan_destination_pack(self, move_line_id, barcode, quantity):
@@ -456,15 +466,19 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
# TODO handle next line and no next line (in a shared way with other
# endpoints)
batch = move_line.picking_id.batch_id
- next_line = self._next_line_for_picking_batch(batch)
+ next_line = self._next_line_for_pick(batch)
if not next_line:
+ # TODO
return self._response(
next_state="start",
- message={"message_type": "info", "message": "Not implemented"},
+ message={
+ "message_type": "error",
+ "message": "no lines remaining, not implemented",
+ },
)
return self._response(
next_state="start_line",
- data=self._data_for_next_move_line(next_line),
+ data=self._data_move_line(next_line),
message={
"message_type": "info",
# TODO different message for products/packs?
@@ -512,15 +526,23 @@ def _data_for_unload(self, move_line):
}
def _response_for_unload_all(self, batch):
- # all the lines destinations are the same here
- first_line = self._first_line_for_picking_batch(batch)
+ # all the lines destinations are the same here, it looks
+ # only for the first one
+ first_line = self._next_line_for_unload_single(batch)
return self._response(
next_state="unload_all", data=self._data_for_unload(first_line)
)
+ def _lines_to_unload(self, batch):
+ return self._lines_for_picking_batch(
+ batch,
+ filter_func=lambda l: l.qty_done > 0
+ and l.result_package_id
+ and not l.shopfloor_unloaded,
+ )
+
def _next_line_for_unload_single(self, batch):
- # TODO: shall we use `_first_line_for_picking_batch` + lambda filter?
- lines = batch.mapped("picking_ids.move_line_ids")
+ lines = self._lines_to_unload(batch)
for line in lines:
if line.shopfloor_unloaded:
continue
@@ -573,12 +595,18 @@ def skip_line(self, move_line_id):
return self._response_for_skip_line(move_line)
def _response_for_skip_line(self, move_line):
- next_line = self._next_line_for_picking_batch(move_line.picking_id.batch_id)
+ next_line = self._next_line_for_pick(move_line.picking_id.batch_id)
if not next_line:
# TODO ensure batch is 'done' and go to start?
- return self._response()
+ return self._response(
+ next_state="start",
+ message={
+ "message_type": "error",
+ "message": "no lines remaining, not implemented",
+ },
+ )
return self._response(
- next_state="start_line", data=self._data_for_next_move_line(next_line)
+ next_state="start_line", data=self._data_move_line(next_line)
)
def stock_issue(self, move_line_id):
@@ -648,7 +676,48 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
generic way to share actions happening on transitions such as "close
the batch"
"""
- return self._response()
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
+ return self._response_batch_does_not_exist()
+
+ lines = self._lines_to_unload(batch)
+ if not lines:
+ # TODO a bit unexpected here but deal with it
+ return self._response()
+
+ lines.shopfloor_unloaded = True
+ for line in lines:
+ # We set the picking to done only when the last line is
+ # unloaded to avoid backorders.
+ picking = line.picking_id
+ if picking.state == "done":
+ continue
+ picking_lines = picking.mapped("move_line_ids")
+ if all(l.shopfloor_unloaded for l in picking_lines):
+ picking.action_done()
+
+ if all(picking.state == "done" for picking in batch.picking_ids):
+ # do not use the 'done()' method because it does many things we
+ # don't care about
+ batch.state = "done"
+ return self._response_batch_complete()
+
+ # TODO add tests for this
+ next_line = self._next_line_for_pick(batch)
+ if next_line:
+ return self._response(
+ next_state="start_line", data=self._data_move_line(next_line)
+ )
+ else:
+ batch.mapped("picking_ids").action_done()
+ batch.state = "done"
+ return self._response_batch_complete()
+
+ def _response_batch_complete(self):
+ return self._response(
+ next_state="start",
+ message={"message_type": "info", "message": _("Batch Transfer complete")},
+ )
def unload_split(self, picking_batch_id, barcode, confirmation=False):
"""Indicates that now the batch must be treated line per line
@@ -788,7 +857,7 @@ def change_pack_lot(self):
def set_destination_all(self):
return {
- "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
"barcode": {"required": True, "type": "string"},
"confirmation": {"type": "boolean", "nullable": True, "required": False},
}
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 463c9951d8..0851b14c04 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -8,20 +8,10 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin):
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
cls.product_a = cls.env["product.product"].create(
- {
- "name": "Product A",
- "type": "product",
- "default_code": "A",
- "barcode": "A",
- }
+ {"name": "Product A", "type": "product", "default_code": "A", "barcode": "A"}
)
cls.product_b = cls.env["product.product"].create(
- {
- "name": "Product B",
- "type": "product",
- "default_code": "B",
- "barcode": "B",
- }
+ {"name": "Product B", "type": "product", "default_code": "B", "barcode": "B"}
)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
cls.process = cls.menu.process_id
@@ -379,9 +369,7 @@ def test_select_not_exists(self):
batch_id = self.batch1.id
self.batch1.unlink()
# Simulate the client selecting the batch in a list
- response = self.service.dispatch(
- "select", params={"picking_batch_id": batch_id}
- )
+ response = self.service.dispatch("select", params={"picking_batch_id": batch_id})
self.assert_response(
response,
next_state="manual_selection",
@@ -771,20 +759,65 @@ def test_scan_destination_pack_ok(self):
)
-class ClusterPickingPrepareUnloadPackCase(ClusterPickingCommonCase):
- """Tests covering the /prepare_unload endpoint
+# TODO tests for transitions to next line / no next lines, ...
- Destination packages have been set on all the move lines of the batch.
- The unload operation will start, but we have 2 paths for this:
- 1. unload all the destination packages at the same place
- 2. unload the destination packages one by one at different places
-
- By default, if all the move lines have the same destination, the
- first path is used. A flag on the batch picking keeps track of which
- path is used.
+class ClusterPickingSkipLineCase(ClusterPickingCommonCase):
+ """Tests covering the /skip_line endpoint
"""
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # quants already existing are from demo data
+ cls.env["stock.quant"].search(
+ [("location_id", "=", cls.stock_location.id)]
+ ).unlink()
+ cls.batch = cls._create_picking_batch(
+ [
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10),
+ cls.BatchProduct(product=cls.product_b, quantity=20),
+ ],
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=30),
+ cls.BatchProduct(product=cls.product_b, quantity=40),
+ ],
+ ]
+ )
+
+ def _skip_line(self, line, next_line=None):
+ response = self.service.dispatch("skip_line", params={"move_line_id": line.id})
+ if next_line:
+ self.assert_response(
+ response, next_state="start_line", data=self._line_data(next_line)
+ )
+ return response
+
+ def test_skip_line(self):
+ self._simulate_batch_selected(self.batch, in_package=True)
+ lines = self.batch.picking_ids.move_line_ids
+ # 1st line, next is 2nd
+ self.assertFalse(lines[0].shopfloor_postponed)
+ self._skip_line(lines[0], lines[1])
+ self.assertTrue(lines[0].shopfloor_postponed)
+ # 2nd line, next is 3rd
+ self.assertFalse(lines[1].shopfloor_postponed)
+ self._skip_line(lines[1], lines[2])
+ self.assertTrue(lines[1].shopfloor_postponed)
+ # 3rd line, next is 4th
+ self.assertFalse(lines[2].shopfloor_postponed)
+ self._skip_line(lines[2], lines[3])
+ self.assertTrue(lines[2].shopfloor_postponed)
+ # 4th line, next is 1st
+ # the next line for the last one is the 1st,
+ # because you'll have to process it anyway
+ self.assertFalse(lines[3].shopfloor_postponed)
+ self._skip_line(lines[3], lines[0])
+ self.assertTrue(lines[3].shopfloor_postponed)
+
+
+class ClusterPickingUnloadingCommonCase(ClusterPickingCommonCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
@@ -818,13 +851,30 @@ def setUpClass(cls, *args, **kwargs):
def _set_dest_package_and_done(self, move_lines, dest_package):
"""Simulate what would have been done in the previous steps"""
for line in move_lines:
- line.write({"qty_done": line.qty_done, "package_id": dest_package.id})
+ line.write(
+ {"qty_done": line.product_uom_qty, "result_package_id": dest_package.id}
+ )
+
+
+class ClusterPickingPrepareUnloadCase(ClusterPickingUnloadingCommonCase):
+ """Tests covering the /prepare_unload endpoint
+
+ Destination packages have been set on all the move lines of the batch.
+ The unload operation will start, but we have 2 paths for this:
+
+ 1. unload all the destination packages at the same place
+ 2. unload the destination packages one by one at different places
+
+ By default, if all the move lines have the same destination, the
+ first path is used. A flag on the batch picking keeps track of which
+ path is used.
+ """
def test_prepare_unload_all_same_dest(self):
"""All move lines have the same destination location"""
move_lines = self.batch.mapped("picking_ids.move_line_ids")
- self._set_dest_package_and_done(move_lines[:1], self.bin1)
- self._set_dest_package_and_done(move_lines[1:], self.bin2)
+ self._set_dest_package_and_done(move_lines[:2], self.bin1)
+ self._set_dest_package_and_done(move_lines[2:], self.bin2)
move_lines.write({"location_dest_id": self.packing_location.id})
response = self.service.dispatch(
"prepare_unload", params={"picking_batch_id": self.batch.id}
@@ -846,8 +896,8 @@ def test_prepare_unload_all_same_dest(self):
def test_prepare_unload_different_dest(self):
"""All move lines have different destination locations"""
move_lines = self.batch.mapped("picking_ids.move_line_ids")
- self._set_dest_package_and_done(move_lines[:1], self.bin1)
- self._set_dest_package_and_done(move_lines[1:], self.bin2)
+ self._set_dest_package_and_done(move_lines[:2], self.bin1)
+ self._set_dest_package_and_done(move_lines[2:], self.bin2)
move_lines[:1].write({"location_dest_id": self.packing_a_location.id})
move_lines[:1].write({"location_dest_id": self.packing_b_location.id})
response = self.service.dispatch(
@@ -869,59 +919,132 @@ def test_prepare_unload_different_dest(self):
)
-class ClusterPickingSkipLineCase(ClusterPickingCommonCase):
- """Tests covering the /skip_line endpoint
+class ClusterPickingSetDestinationAllCase(ClusterPickingUnloadingCommonCase):
+ """Tests covering the /set_destination_all endpoint
+
+ All the picked lines go to the same destination, a single call to this
+ endpoint set them as "unloaded" and set the destination. When the last
+ available line of a picking is unloaded, the picking is set to 'done'.
"""
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
- # quants already existing are from demo data
- cls.env["stock.quant"].search(
- [("location_id", "=", cls.stock_location.id)]
- ).unlink()
- cls.batch = cls._create_picking_batch(
+ # this is what the /prepare_endpoint method would have set as all the
+ # destinations are the same:
+ cls.batch.cluster_picking_unload_all = True
+
+ def test_set_destination_all_ok(self):
+ """Set destination on all lines for the full batch and end the process"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ # put destination packages, the whole quantity on lines and a similar
+ # destination (when /set_destination_all is called, all the lines to
+ # unload must have the same destination)
+ self._set_dest_package_and_done(move_lines[:2], self.bin1)
+ self._set_dest_package_and_done(move_lines[2:], self.bin2)
+ move_lines.write({"location_dest_id": self.packing_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ # since the whole batch is complete, we expect the batch and all
+ # pickings to be 'done'
+ self.assertRecordValues(
+ move_lines.mapped("picking_id"), [{"state": "done"}, {"state": "done"}]
+ )
+ self.assertRecordValues(
+ move_lines,
[
- [
- cls.BatchProduct(product=cls.product_a, quantity=10),
- cls.BatchProduct(product=cls.product_b, quantity=20),
- ],
- [
- cls.BatchProduct(product=cls.product_a, quantity=30),
- cls.BatchProduct(product=cls.product_b, quantity=40),
- ],
- ]
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ "state": "done",
+ "location_dest_id": self.packing_location.id,
+ },
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ "state": "done",
+ "location_dest_id": self.packing_location.id,
+ },
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ "state": "done",
+ "location_dest_id": self.packing_location.id,
+ },
+ ],
+ )
+ self.assertRecordValues(self.batch, [{"state": "done"}])
+ self.assert_response(
+ response,
+ next_state="start",
+ message={"message_type": "info", "message": "Batch Transfer complete"},
)
- def _skip_line(self, line, next_line=None):
- response = self.service.dispatch("skip_line", params={"move_line_id": line.id})
- if next_line:
- self.assert_response(
- response, next_state="start_line", data=self._line_data(next_line)
- )
- return response
+ def test_set_destination_all_remaining_lines(self):
+ """Set destination on all lines for a part of the batch"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ # Put destination packages, the whole quantity on lines and a similar
+ # destination (when /set_destination_all is called, all the lines to
+ # unload must have the same destination).
+ # However, we keep a line without qty_done and destination package,
+ # so when the dest location is set, the endpoint should route back
+ # to the 'start_line' state to work on the remaining line.
+ lines_to_unload = move_lines[:2]
+ self._set_dest_package_and_done(lines_to_unload, self.bin1)
+ lines_to_unload.write({"location_dest_id": self.packing_location.id})
- def test_skip_line(self):
- self._simulate_batch_selected(self.batch, in_package=True)
- lines = self.batch.picking_ids.move_line_ids
- # 1st line, next is 2nd
- self.assertFalse(lines[0].shopfloor_postponed)
- self._skip_line(lines[0], lines[1])
- self.assertTrue(lines[0].shopfloor_postponed)
- # 2nd line, next is 3rd
- self.assertFalse(lines[1].shopfloor_postponed)
- self._skip_line(lines[1], lines[2])
- self.assertTrue(lines[1].shopfloor_postponed)
- # 3rd line, next is 4th
- self.assertFalse(lines[2].shopfloor_postponed)
- self._skip_line(lines[2], lines[3])
- self.assertTrue(lines[2].shopfloor_postponed)
- # 4th line, next is 1st
- # the next line for the last one is the 1st,
- # because you'll have to process it anyway
- self.assertFalse(lines[3].shopfloor_postponed)
- self._skip_line(lines[3], lines[0])
- self.assertTrue(lines[3].shopfloor_postponed)
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ # Since the whole batch is not complete, state should not be done.
+ # The picking with one line should be "done" because we unloaded its line.
+ # The second one still has a line to pick.
+ one_line_picking = self.batch.picking_ids[0]
+ two_lines_picking = self.batch.picking_ids[1]
+ self.assertRecordValues(one_line_picking, [{"state": "done"}])
+ self.assertRecordValues(two_lines_picking, [{"state": "assigned"}])
+ self.assertRecordValues(
+ move_lines,
+ [
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ "state": "done",
+ "picking_id": one_line_picking.id,
+ "location_dest_id": self.packing_location.id,
+ },
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ # will be done when the second line of the picking is unloaded
+ "state": "assigned",
+ "picking_id": two_lines_picking.id,
+ "location_dest_id": self.packing_location.id,
+ },
+ {
+ "shopfloor_unloaded": False,
+ "qty_done": 0,
+ "state": "assigned",
+ "picking_id": two_lines_picking.id,
+ "location_dest_id": self.packing_location.id,
+ },
+ ],
+ )
+ self.assertRecordValues(self.batch, [{"state": "in_progress"}])
+ self.assert_response(
+ response, next_state="start_line", data=self._line_data(move_lines[2])
+ )
-# TODO tests for transitions to next line / no next lines, ...
+ # TODO add test when /set_destination_all is called but not all lines
+ # have the same destination
From 181df25dfd72c524d15e9b2acde23fbd24e1067a Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 25 Feb 2020 15:30:08 +0100
Subject: [PATCH 112/940] cluster_picking: refine /set_destination_all
---
shopfloor/models/__init__.py | 1 +
shopfloor/models/stock_quant_package.py | 12 ++
shopfloor/services/cluster_picking.py | 120 +++++++++++++----
shopfloor/tests/test_cluster_picking.py | 169 ++++++++++++++++++++++--
4 files changed, 265 insertions(+), 37 deletions(-)
create mode 100644 shopfloor/models/stock_quant_package.py
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index 1cc7ee589a..e9f59c02e3 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -6,4 +6,5 @@
from . import stock_location
from . import stock_move_line
from . import stock_picking_batch
+from . import stock_quant_package
from . import res_users
diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py
new file mode 100644
index 0000000000..63cbf4ab8c
--- /dev/null
+++ b/shopfloor/models/stock_quant_package.py
@@ -0,0 +1,12 @@
+from odoo import fields, models
+
+
+class StockQuantPackage(models.Model):
+ _inherit = "stock.quant.package"
+
+ dest_move_line_ids = fields.One2many(
+ comodel_name="stock.move.line",
+ inverse_name="result_package_id",
+ readonly=True,
+ help="Technical field. Move lines for which destination" " is this package.",
+ )
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index dc4ab7078d..95a399d73c 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -112,7 +112,8 @@ def list_batch(self):
def _select_a_picking_batch(self, batches):
# look for in progress + assigned to self first
candidates = batches.filtered(
- lambda batch: batch.state == "in_progress" and batch.user_id == self.env.user
+ lambda batch: batch.state == "in_progress"
+ and batch.user_id == self.env.user
)
if candidates:
return candidates[0]
@@ -490,6 +491,10 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
},
)
+ def _are_all_dest_location_same(self, batch):
+ lines_to_unload = self._lines_to_unload(batch)
+ return len(lines_to_unload.mapped("location_dest_id")) == 1
+
def prepare_unload(self, picking_batch_id):
"""Initiate the unloading phase of the process
@@ -506,7 +511,7 @@ def prepare_unload(self, picking_batch_id):
batch = self.env["stock.picking.batch"].browse(picking_batch_id)
if not batch.exists():
return self._response_batch_does_not_exist()
- if len(batch.mapped("picking_ids.move_line_ids.location_dest_id")) == 1:
+ if self._are_all_dest_location_same(batch):
batch.cluster_picking_unload_all = True
return self._response_for_unload_all(batch)
else:
@@ -514,48 +519,73 @@ def prepare_unload(self, picking_batch_id):
batch.cluster_picking_unload_all = False
return self._response_for_unload_single(batch)
- def _data_for_unload(self, move_line):
- batch = move_line.picking_id.batch_id
+ def _data_for_unload_all(self, batch):
+ lines = self._lines_to_unload(batch)
+ # all the lines destinations are the same here, it looks
+ # only for the first one
+ first_line = fields.first(lines)
return {
"id": batch.id,
"name": batch.name,
"location_dst": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
+ "id": first_line.location_dest_id.id,
+ "name": first_line.location_dest_id.name,
},
}
- def _response_for_unload_all(self, batch):
- # all the lines destinations are the same here, it looks
- # only for the first one
- first_line = self._next_line_for_unload_single(batch)
+ def _data_for_unload_single(self, batch, package):
+ line = fields.first(
+ package.dest_move_line_ids.filtered(self._filter_for_unload)
+ )
+ return {
+ # TODO disambiguate "id" everywhere? (id -> package_id)
+ "id": package.id,
+ "name": package.name,
+ "location_dst": {
+ "id": line.location_dest_id.id,
+ "name": line.location_dest_id.name,
+ },
+ }
+
+ def _response_for_unload_all(self, batch, message=None):
return self._response(
- next_state="unload_all", data=self._data_for_unload(first_line)
+ next_state="unload_all",
+ data=self._data_for_unload_all(batch),
+ message=message,
)
- def _lines_to_unload(self, batch):
- return self._lines_for_picking_batch(
- batch,
- filter_func=lambda l: l.qty_done > 0
- and l.result_package_id
- and not l.shopfloor_unloaded,
+ def _response_for_unload_all_need_confirm(self, batch, message=None):
+ return self._response(
+ next_state="confirm_unload_all",
+ data=self._data_for_unload_all(batch),
+ message=message,
)
- def _next_line_for_unload_single(self, batch):
+ def _filter_for_unload(self, line):
+ return (
+ line.qty_done > 0 and line.result_package_id and not line.shopfloor_unloaded
+ )
+
+ def _lines_to_unload(self, batch):
+ return self._lines_for_picking_batch(batch, filter_func=self._filter_for_unload)
+
+ def _bin_packages_to_unload(self, batch):
lines = self._lines_to_unload(batch)
- for line in lines:
- if line.shopfloor_unloaded:
- continue
- return line
- return self.env["stock.move.line"].browse()
+ packages = lines.mapped("result_package_id")
+ return packages
+
+ def _next_bin_package_for_unload_single(self, batch):
+ packages = self._bin_packages_to_unload(batch)
+ return fields.first(packages)
def _response_for_unload_single(self, batch):
- next_line = self._next_line_for_unload_single(batch)
- if not next_line:
+ next_package = self._next_bin_package_for_unload_single(batch)
+ if not next_package:
# TODO ensure batch is 'done' and go to start?
return self._response()
return self._response(
- next_state="unload_single", data=self._data_for_unload(next_line)
+ next_state="unload_single",
+ data=self._data_for_unload_single(batch, next_package),
)
def is_zero(self, move_line_id, zero):
@@ -680,12 +710,39 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
if not batch.exists():
return self._response_batch_does_not_exist()
+ message = self.actions_for("message")
+
+ # In case /set_destination_all was called and the destinations were
+ # in fact no the same... restart the unloading step over
+ if not self._are_all_dest_location_same(batch):
+ return self.prepare_unload(batch.id)
+
lines = self._lines_to_unload(batch)
if not lines:
# TODO a bit unexpected here but deal with it
return self._response()
- lines.shopfloor_unloaded = True
+ first_line = fields.first(lines)
+ picking_type = fields.first(batch.picking_ids).picking_type_id
+ scanned_location = self.actions_for("search").location_from_scan(barcode)
+ if not scanned_location:
+ return self._response_for_unload_all(
+ batch, message=message.no_location_found()
+ )
+ if not scanned_location.is_sublocation_of(
+ picking_type.default_location_dest_id
+ ):
+ return self._response_for_unload_all(
+ batch, message=message.dest_location_not_allowed()
+ )
+
+ if not scanned_location.is_sublocation_of(first_line.location_dest_id):
+ if not confirmation:
+ return self._response_for_unload_all_need_confirm(batch)
+
+ lines.write(
+ {"shopfloor_unloaded": True, "location_dest_id": scanned_location.id}
+ )
for line in lines:
# We set the picking to done only when the last line is
# unloaded to avoid backorders.
@@ -702,13 +759,15 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
batch.state = "done"
return self._response_batch_complete()
- # TODO add tests for this
next_line = self._next_line_for_pick(batch)
if next_line:
return self._response(
next_state="start_line", data=self._data_move_line(next_line)
)
else:
+ # TODO add tests for this (for instance a picking is not 'done'
+ # because a move was unassigned, we want to validate the batch to
+ # produce backorders)
batch.mapped("picking_ids").action_done()
batch.state = "done"
return self._response_batch_complete()
@@ -1012,6 +1071,9 @@ def set_destination_all(self):
"start_line",
# invalid destination, have to scan a valid one
"unload_all",
+ # this endpoint was called but after checking, lines
+ # have different destination locations
+ "unload_single",
# different destination to confirm
"confirm_unload_all",
# batch finished
@@ -1183,7 +1245,7 @@ def _schema_for_unload_all(self):
@property
def _schema_for_unload_single(self):
return {
- # stock.move.line
+ # stock.quant.package
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"location_dst": {
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 0851b14c04..7547df2f46 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -8,15 +8,26 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin):
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
cls.product_a = cls.env["product.product"].create(
- {"name": "Product A", "type": "product", "default_code": "A", "barcode": "A"}
+ {
+ "name": "Product A",
+ "type": "product",
+ "default_code": "A",
+ "barcode": "A",
+ }
)
cls.product_b = cls.env["product.product"].create(
- {"name": "Product B", "type": "product", "default_code": "B", "barcode": "B"}
+ {
+ "name": "Product B",
+ "type": "product",
+ "default_code": "B",
+ "barcode": "B",
+ }
)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
+ cls.wh.delivery_steps = "pick_pack_ship"
cls.picking_type = cls.process.picking_type_id
def setUp(self):
@@ -369,7 +380,9 @@ def test_select_not_exists(self):
batch_id = self.batch1.id
self.batch1.unlink()
# Simulate the client selecting the batch in a list
- response = self.service.dispatch("select", params={"picking_batch_id": batch_id})
+ response = self.service.dispatch(
+ "select", params={"picking_batch_id": batch_id}
+ )
self.assert_response(
response,
next_state="manual_selection",
@@ -909,8 +922,8 @@ def test_prepare_unload_different_dest(self):
response,
next_state="unload_single",
data={
- "id": self.batch.id,
- "name": self.batch.name,
+ "id": self.bin1.id,
+ "name": self.bin1.name,
"location_dst": {
"id": first_line.location_dest_id.id,
"name": first_line.location_dest_id.name,
@@ -1043,8 +1056,148 @@ def test_set_destination_all_remaining_lines(self):
self.assertRecordValues(self.batch, [{"state": "in_progress"}])
self.assert_response(
- response, next_state="start_line", data=self._line_data(move_lines[2])
+ # the remaining move line still needs to be picked
+ response,
+ next_state="start_line",
+ data=self._line_data(move_lines[2]),
)
- # TODO add test when /set_destination_all is called but not all lines
- # have the same destination
+ def test_set_destination_all_but_different_dest(self):
+ """Endpoint was called but destinations are different"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines[:2].write({"location_dest_id": self.packing_a_location.id})
+ move_lines[2:].write({"location_dest_id": self.packing_b_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
+ self.assert_response(
+ response,
+ next_state="unload_single",
+ data={
+ "id": self.bin1.id,
+ "name": self.bin1.name,
+ "location_dst": {
+ "id": move_lines[0].location_dest_id.id,
+ "name": move_lines[0].location_dest_id.name,
+ },
+ },
+ )
+
+ def test_set_destination_all_error_location_not_found(self):
+ """Endpoint called with a barcode not existing for a location"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines.write({"location_dest_id": self.packing_a_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={"picking_batch_id": self.batch.id, "barcode": "NOTFOUND"},
+ )
+ self.assert_response(
+ response,
+ next_state="unload_all",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "location_dst": {
+ "id": move_lines[0].location_dest_id.id,
+ "name": move_lines[0].location_dest_id.name,
+ },
+ },
+ message={
+ "message_type": "error",
+ "message": "No location found for this barcode.",
+ },
+ )
+
+ def test_set_destination_all_error_location_invalid(self):
+ """Endpoint called with a barcode for an invalid location
+
+ It is invalid when the location is not the destination location or
+ sublocation of the picking type.
+ """
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines.write({"location_dest_id": self.packing_a_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.dispatch_location.barcode,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="unload_all",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "location_dst": {
+ "id": move_lines[0].location_dest_id.id,
+ "name": move_lines[0].location_dest_id.name,
+ },
+ },
+ message={"message_type": "error", "message": "You cannot place it here"},
+ )
+
+ def test_set_destination_all_need_confirmation(self):
+ """Endpoint called with a barcode for another (valid) location"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines.write({"location_dest_id": self.packing_a_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_b_location.barcode,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="confirm_unload_all",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "location_dst": {
+ "id": move_lines[0].location_dest_id.id,
+ "name": move_lines[0].location_dest_id.name,
+ },
+ },
+ )
+
+ def test_set_destination_all_with_confirmation(self):
+ """Endpoint called with a barcode for another (valid) location, confirm"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines.write({"location_dest_id": self.packing_a_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_b_location.barcode,
+ "confirmation": True,
+ },
+ )
+ self.assertRecordValues(
+ move_lines,
+ [
+ {"location_dest_id": self.packing_b_location.id},
+ {"location_dest_id": self.packing_b_location.id},
+ {"location_dest_id": self.packing_b_location.id},
+ ],
+ )
+ self.assert_response(
+ response,
+ next_state="start",
+ message={"message_type": "info", "message": "Batch Transfer complete"},
+ )
From ec4c97dc741d8460feaea2950ca2ba781a719278 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 25 Feb 2020 16:14:19 +0100
Subject: [PATCH 113/940] cluster_picking: add /unload_split
---
shopfloor/services/cluster_picking.py | 10 ++++--
shopfloor/tests/test_cluster_picking.py | 44 +++++++++++++++++++++++++
2 files changed, 52 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 95a399d73c..4edaf8bb26 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -778,7 +778,7 @@ def _response_batch_complete(self):
message={"message_type": "info", "message": _("Batch Transfer complete")},
)
- def unload_split(self, picking_batch_id, barcode, confirmation=False):
+ def unload_split(self, picking_batch_id):
"""Indicates that now the batch must be treated line per line
Even if the move lines to unload all have the same destination.
@@ -792,7 +792,13 @@ def unload_split(self, picking_batch_id, barcode, confirmation=False):
Transitions:
* unload_single: always goes here since we now want to unload line per line
"""
- return self._response()
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
+ return self._response_batch_does_not_exist()
+
+ batch.cluster_picking_unload_all = False
+
+ return self._response_for_unload_single(batch)
def unload_router(self, picking_batch_id):
"""Called after the info screen, route to the next state
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 7547df2f46..8609c989da 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -1201,3 +1201,47 @@ def test_set_destination_all_with_confirmation(self):
next_state="start",
message={"message_type": "info", "message": "Batch Transfer complete"},
)
+
+
+class ClusterPickingUnloadSplitCase(ClusterPickingUnloadingCommonCase):
+ """Tests covering the /unload_split endpoint
+
+ All the destinations of the bins were the same so the "unload all" screen
+ was presented to the user, but they want different destination, so they hit
+ the "split" button. From now on, the workflow should use the "unload single"
+ screen even if the destinations are the same.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # this is what the /prepare_endpoint method would have set as all the
+ # destinations are the same:
+ cls.batch.cluster_picking_unload_all = True
+
+ def test_unload_split_ok(self):
+ """Call /unload_split and continue to unload single"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ # put destination packages, the whole quantity on lines and a similar
+ # destination (when /set_destination_all is called, all the lines to
+ # unload must have the same destination)
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines.write({"location_dest_id": self.packing_location.id})
+
+ response = self.service.dispatch(
+ "unload_split", params={"picking_batch_id": self.batch.id}
+ )
+ self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
+ self.assert_response(
+ # the remaining move line still needs to be picked
+ response,
+ next_state="unload_single",
+ data={
+ "id": self.bin1.id,
+ "name": self.bin1.name,
+ "location_dst": {
+ "id": move_lines[0].location_dest_id.id,
+ "name": move_lines[0].location_dest_id.name,
+ },
+ },
+ )
From cb85a9b269e78cae9588e0e79b6afe2c7f98b04a Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 25 Feb 2020 17:01:47 +0100
Subject: [PATCH 114/940] cluster_picking add /unload_scan_pack
---
shopfloor/services/cluster_picking.py | 44 ++++++++++---
shopfloor/tests/test_cluster_picking.py | 87 +++++++++++++++++++++++--
2 files changed, 116 insertions(+), 15 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 4edaf8bb26..f3400b21ea 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -538,9 +538,10 @@ def _data_for_unload_single(self, batch, package):
package.dest_move_line_ids.filtered(self._filter_for_unload)
)
return {
- # TODO disambiguate "id" everywhere? (id -> package_id)
- "id": package.id,
- "name": package.name,
+ # TODO disambiguate "id" everywhere? (id -> picking_batch_id)
+ "id": batch.id,
+ "name": batch.name,
+ "package": {"id": package.id, "name": package.name},
"location_dst": {
"id": line.location_dest_id.id,
"name": line.location_dest_id.name,
@@ -815,7 +816,7 @@ def unload_router(self, picking_batch_id):
"""
return self._response()
- def unload_scan_pack(self, package_id, barcode):
+ def unload_scan_pack(self, picking_batch_id, package_id, barcode):
"""Check that the operator scans the correct package (bin) on unload
If the scanned barcode is not the one of the Bin (package), ask to scan
@@ -825,9 +826,27 @@ def unload_scan_pack(self, package_id, barcode):
* unload_single: if the barcode does not match
* unload_set_destination: barcode is correct
"""
- return self._response()
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
+ return self._response_batch_does_not_exist()
+ package = self.env["stock.quant.package"].browse(package_id)
+ if not package.exists():
+ # TODO: next package? if no next package, close and go to start?
+ return self._response()
+ if package.name != barcode:
+ return self._response(
+ next_state="unload_single",
+ data=self._data_for_unload_single(batch, package),
+ message={"message_type": "error", "message": _("Wrong bin")},
+ )
+ return self._response(
+ next_state="unload_set_destination",
+ data=self._data_for_unload_single(batch, package),
+ )
- def unload_scan_destination(self, package_id, barcode, confirmation=False):
+ def unload_scan_destination(
+ self, picking_batch_id, package_id, barcode, confirmation=False
+ ):
"""Scan the final destination for all the move lines moved with the Bin
It updates all the assigned move lines with the package to the
@@ -939,13 +958,15 @@ def unload_router(self):
def unload_scan_pack(self):
return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
"package_id": {"coerce": to_int, "required": True, "type": "integer"},
"barcode": {"required": True, "type": "string"},
}
def unload_scan_destination(self):
return {
- "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
"barcode": {"required": True, "type": "string"},
"confirmation": {"type": "boolean", "nullable": True, "required": False},
}
@@ -1251,9 +1272,16 @@ def _schema_for_unload_all(self):
@property
def _schema_for_unload_single(self):
return {
- # stock.quant.package
+ # stock.batch.picking
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
+ "package": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
"location_dst": {
"type": "dict",
"schema": {
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
index 8609c989da..e9a5674a0d 100644
--- a/shopfloor/tests/test_cluster_picking.py
+++ b/shopfloor/tests/test_cluster_picking.py
@@ -861,7 +861,8 @@ def setUpClass(cls, *args, **kwargs):
}
)
- def _set_dest_package_and_done(self, move_lines, dest_package):
+ @classmethod
+ def _set_dest_package_and_done(cls, move_lines, dest_package):
"""Simulate what would have been done in the previous steps"""
for line in move_lines:
line.write(
@@ -922,8 +923,9 @@ def test_prepare_unload_different_dest(self):
response,
next_state="unload_single",
data={
- "id": self.bin1.id,
- "name": self.bin1.name,
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
"location_dst": {
"id": first_line.location_dest_id.id,
"name": first_line.location_dest_id.name,
@@ -1081,8 +1083,9 @@ def test_set_destination_all_but_different_dest(self):
response,
next_state="unload_single",
data={
- "id": self.bin1.id,
- "name": self.bin1.name,
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
"location_dst": {
"id": move_lines[0].location_dest_id.id,
"name": move_lines[0].location_dest_id.name,
@@ -1237,11 +1240,81 @@ def test_unload_split_ok(self):
response,
next_state="unload_single",
data={
- "id": self.bin1.id,
- "name": self.bin1.name,
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
"location_dst": {
"id": move_lines[0].location_dest_id.id,
"name": move_lines[0].location_dest_id.name,
},
},
)
+
+
+class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase):
+ """Tests covering the /unload_scan_pack endpoint
+
+ Goods have been put in the package bins, they have different destinations
+ or /unload_split has been called, now user has to unload package per
+ package. For this, they'll first scan the bin package, which will call the
+ endpoint /unload_scan_pack. (second step will be to set the destination
+ with /unload_scan_destination, in a different test case)
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.batch.cluster_picking_unload_all = False
+ cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids")
+ cls._set_dest_package_and_done(cls.move_lines, cls.bin1)
+ cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id})
+ cls.move_lines[2:].write({"location_dest_id": cls.packing_b_location.id})
+
+ def test_unload_scan_pack_ok(self):
+ """Endpoint /unload_scan_pack is called, result ok"""
+ response = self.service.dispatch(
+ "unload_scan_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin1.id,
+ "barcode": self.bin1.name,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="unload_set_destination",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
+ "location_dst": {
+ "id": self.move_lines[0].location_dest_id.id,
+ "name": self.move_lines[0].location_dest_id.name,
+ },
+ },
+ )
+
+ def test_unload_scan_pack_wrong_barcode(self):
+ """Endpoint /unload_scan_pack is called, wrong barcode scanned"""
+ response = self.service.dispatch(
+ "unload_scan_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin1.id,
+ "barcode": self.bin2.name,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="unload_single",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
+ "location_dst": {
+ "id": self.move_lines[0].location_dest_id.id,
+ "name": self.move_lines[0].location_dest_id.name,
+ },
+ },
+ message={"message_type": "error", "message": "Wrong bin"},
+ )
From bc4c90781a5694c03f52ebe8a7cdb2578bd3cb54 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 26 Feb 2020 10:31:37 +0100
Subject: [PATCH 115/940] backend: split cluster_picking tests
---
shopfloor/tests/__init__.py | 6 +-
shopfloor/tests/test_cluster_picking.py | 1320 -----------------
shopfloor/tests/test_cluster_picking_base.py | 116 ++
shopfloor/tests/test_cluster_picking_scan.py | 236 +++
.../tests/test_cluster_picking_select.py | 428 ++++++
shopfloor/tests/test_cluster_picking_skip.py | 59 +
.../tests/test_cluster_picking_unload.py | 491 ++++++
7 files changed, 1335 insertions(+), 1321 deletions(-)
delete mode 100644 shopfloor/tests/test_cluster_picking.py
create mode 100644 shopfloor/tests/test_cluster_picking_base.py
create mode 100644 shopfloor/tests/test_cluster_picking_scan.py
create mode 100644 shopfloor/tests/test_cluster_picking_select.py
create mode 100644 shopfloor/tests/test_cluster_picking_skip.py
create mode 100644 shopfloor/tests/test_cluster_picking_unload.py
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index e4331331dd..8e179399c0 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -4,5 +4,9 @@
from . import test_picking_batch
from . import test_single_pack_putaway
from . import test_single_pack_transfer
-from . import test_cluster_picking
+from . import test_cluster_picking_base
+from . import test_cluster_picking_select
+from . import test_cluster_picking_scan
+from . import test_cluster_picking_skip
+from . import test_cluster_picking_unload
from . import test_checkout
diff --git a/shopfloor/tests/test_cluster_picking.py b/shopfloor/tests/test_cluster_picking.py
deleted file mode 100644
index e9a5674a0d..0000000000
--- a/shopfloor/tests/test_cluster_picking.py
+++ /dev/null
@@ -1,1320 +0,0 @@
-import unittest
-
-from .common import CommonCase, PickingBatchMixin
-
-
-class ClusterPickingCommonCase(CommonCase, PickingBatchMixin):
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- cls.product_a = cls.env["product.product"].create(
- {
- "name": "Product A",
- "type": "product",
- "default_code": "A",
- "barcode": "A",
- }
- )
- cls.product_b = cls.env["product.product"].create(
- {
- "name": "Product B",
- "type": "product",
- "default_code": "B",
- "barcode": "B",
- }
- )
- cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
- cls.process = cls.menu.process_id
- cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
- cls.wh = cls.profile.warehouse_id
- cls.wh.delivery_steps = "pick_pack_ship"
- cls.picking_type = cls.process.picking_type_id
-
- def setUp(self):
- super().setUp()
- with self.work_on_services(menu=self.menu, profile=self.profile) as work:
- self.service = work.component(usage="cluster_picking")
-
- @classmethod
- def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False):
- """Create a state as if a batch was selected by the user
-
- * The picking batch is in progress
- * It is assigned to the current user
- * All the move lines are available
-
- Note: currently, this method create a source package that contains
- all the products of the batch. It is enough for the current tests.
- """
- pickings = batches.mapped("picking_ids")
- cls._fill_stock_for_moves(
- pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot
- )
- pickings.action_assign()
- batches.write({"state": "in_progress", "user_id": cls.env.uid})
-
- def _line_data(self, move_line, qty=None):
- picking = move_line.picking_id
- batch = picking.batch_id
- # A package exists on the move line, because the quant created
- # by ``_simulate_batch_selected`` has a package.
- package = move_line.package_id
- lot = move_line.lot_id
- return {
- "id": move_line.id,
- "quantity": qty or move_line.product_uom_qty,
- "postponed": move_line.shopfloor_postponed,
- "location_dst": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "picking": {
- "id": picking.id,
- "name": picking.name,
- "note": "",
- "origin": picking.origin,
- },
- "batch": {"id": batch.id, "name": batch.name},
- "product": {
- "default_code": move_line.product_id.default_code,
- "display_name": move_line.product_id.display_name,
- "id": move_line.product_id.id,
- "name": move_line.product_id.name,
- "qty_available": move_line.product_id.qty_available,
- },
- "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""}
- if lot
- else None,
- "pack": {"id": package.id, "name": package.name} if package else None,
- }
-
-
-class ClusterPickingAPICase(ClusterPickingCommonCase):
- """Base tests for the cluster picking API"""
-
- def test_to_openapi(self):
- # will raise if it fails to generate the openapi specs
- self.service.to_openapi()
-
-
-class ClusterPickingSelectionCase(ClusterPickingCommonCase):
- """Tests covering the selection of picking batches
-
- Endpoints:
-
- * /cluster_picking/find_batch
- * /cluster_picking/list_batch
- * /cluster_picking/select
- * /cluster_picking/unassign
-
- These endpoints interact with a list of picking batches.
- The other endpoints that interact with a single batch (after selection)
- are handled in other classes.
- """
-
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- # drop base demo data and create our own batches to work with
- cls.env["stock.picking.batch"].search([]).unlink()
- cls.batch1 = cls._create_picking_batch(
- [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
- )
- cls.batch2 = cls._create_picking_batch(
- [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
- )
- cls.batch3 = cls._create_picking_batch(
- [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
- )
- cls.batch4 = cls._create_picking_batch(
- [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
- )
-
- def _add_stock_and_assign_pickings_for_batches(self, batches):
- pickings = batches.mapped("picking_ids")
- self._fill_stock_for_moves(pickings.mapped("move_lines"))
- pickings.action_assign()
-
- def test_find_batch_in_progress_current_user(self):
- """Find an in-progress batch assigned to the current user"""
- # Simulate the client asking a batch by clicking on "get work"
- self._add_stock_and_assign_pickings_for_batches(
- self.batch1 | self.batch2 | self.batch3
- )
- self.batch3.user_id = self.env.uid
- self.batch3.confirm_picking() # set to in progress
- response = self.service.dispatch("find_batch")
-
- # we expect to find batch 3 as it's assigned to the current
- # user and in progress (first priority)
- self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch3.id,
- "name": self.batch3.name,
- # TODO
- "weight": 0,
- "pickings": [
- {
- "id": self.batch3.picking_ids.id,
- "name": self.batch3.picking_ids.name,
- "move_line_count": len(self.batch3.picking_ids.move_line_ids),
- "origin": self.batch3.picking_ids.origin,
- "partner": {
- "id": self.batch3.picking_ids.partner_id.id,
- "name": self.batch3.picking_ids.partner_id.name,
- },
- }
- ],
- },
- )
-
- def test_find_batch_assigned(self):
- """Find a draft batch assigned to the current user"""
- # batches must have all their pickings available to be selected
- self._add_stock_and_assign_pickings_for_batches(
- self.batch1 | self.batch2 | self.batch3
- )
- # batch2 in draft but assigned to the current user should be
- # selected before the others
- self.batch2.user_id = self.env.uid
- response = self.service.dispatch("find_batch")
-
- # The endpoint starts the batch
- self.assertEqual(self.batch2.state, "in_progress")
-
- # we expect to find batch 2 as it's assigned to the current user
- self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch2.id,
- "name": self.batch2.name,
- # TODO
- "weight": 0,
- "pickings": [
- {
- "id": self.batch2.picking_ids.id,
- "name": self.batch2.picking_ids.name,
- "move_line_count": len(self.batch2.picking_ids.move_line_ids),
- "origin": self.batch2.picking_ids.origin,
- "partner": {
- "id": self.batch2.picking_ids.partner_id.id,
- "name": self.batch2.picking_ids.partner_id.name,
- },
- }
- ],
- },
- )
-
- def test_find_batch_unassigned_draft(self):
- """Find a draft batch"""
- # batches must have all their pickings available to be selected
- self._add_stock_and_assign_pickings_for_batches(self.batch2 | self.batch3)
- # batch1 has not all pickings available, so the first draft
- # is batch2, should be selected
- response = self.service.dispatch("find_batch")
-
- # The endpoint starts the batch and assign it to self
- self.assertEqual(self.batch2.user_id, self.env.user)
- self.assertEqual(self.batch2.state, "in_progress")
-
- # we expect to find batch 2 as it's the first one with all pickings
- # available
- self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch2.id,
- "name": self.batch2.name,
- # TODO
- "weight": 0,
- "pickings": [
- {
- "id": self.batch2.picking_ids.id,
- "name": self.batch2.picking_ids.name,
- "move_line_count": len(self.batch2.picking_ids.move_line_ids),
- "origin": self.batch2.picking_ids.origin,
- "partner": {
- "id": self.batch2.picking_ids.partner_id.id,
- "name": self.batch2.picking_ids.partner_id.name,
- },
- }
- ],
- },
- )
-
- def test_find_batch_not_found(self):
- """No batch to work on"""
- # No batch match the rules to work on them, because
- # their pickings are not available
- response = self.service.dispatch("find_batch")
-
- self.assert_response(
- response,
- next_state="start",
- message={
- "message_type": "info",
- "message": "No more work to do, please create a new batch transfer",
- },
- )
-
- def test_list_batch(self):
- """List all available batches"""
- # batches must have all their pickings available to be selected
- self._add_stock_and_assign_pickings_for_batches(
- self.batch1 | self.batch2 | self.batch3
- )
- self.batch1.write({"state": "in_progress", "user_id": self.env.uid})
- self.batch2.write(
- {"state": "in_progress", "user_id": self.env.ref("base.user_demo")}
- )
- self.batch3.write({"state": "draft", "user_id": False})
-
- self.assertEqual(
- self.env["stock.picking.batch"].search([]),
- self.batch1 + self.batch2 + self.batch3 + self.batch4,
- )
- # Simulate the client asking the list of batches
- response = self.service.dispatch("list_batch")
- self.assert_response(
- response,
- next_state="manual_selection",
- data={
- "size": 2,
- "records": [
- {
- "id": self.batch1.id,
- "name": self.batch1.name,
- "picking_count": 1,
- "move_line_count": 1,
- },
- # batch 2 is excluded because assigned to someone else
- {
- "id": self.batch3.id,
- "name": self.batch3.name,
- "picking_count": 1,
- "move_line_count": 1,
- },
- # batch 4 is excluded because not all of its pickings are
- # assigned
- ],
- },
- )
-
- def test_select_in_progress_assigned(self):
- """Select an in-progress batch assigned to the current user"""
- self._add_stock_and_assign_pickings_for_batches(self.batch1)
- self.batch1.write({"state": "in_progress", "user_id": self.env.uid})
- # Simulate the client selecting the batch in a list
- response = self.service.dispatch(
- "select", params={"picking_batch_id": self.batch1.id}
- )
- self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch1.id,
- "name": self.batch1.name,
- # we don't care in these tests, the 'find_batch' tests already
- # check this
- "weight": self.ANY,
- "pickings": self.ANY,
- },
- )
-
- def test_select_draft_assigned(self):
- """Select a draft batch assigned to the current user"""
- self._add_stock_and_assign_pickings_for_batches(self.batch1)
- self.batch1.write({"user_id": self.env.uid})
- # Simulate the client selecting the batch in a list
- response = self.service.dispatch(
- "select", params={"picking_batch_id": self.batch1.id}
- )
- # The endpoint starts the batch and assign it to self
- self.assertEqual(self.batch1.user_id, self.env.user)
- self.assertEqual(self.batch1.state, "in_progress")
- self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch1.id,
- "name": self.batch1.name,
- # we don't care in these tests, the 'find_batch' tests already
- # check this
- "weight": self.ANY,
- "pickings": self.ANY,
- },
- )
-
- def test_select_draft_unassigned(self):
- """Select a draft batch not assigned to a user"""
- self._add_stock_and_assign_pickings_for_batches(self.batch1)
- # Simulate the client selecting the batch in a list
- response = self.service.dispatch(
- "select", params={"picking_batch_id": self.batch1.id}
- )
- # The endpoint starts the batch and assign it to self
- self.assertEqual(self.batch1.user_id, self.env.user)
- self.assertEqual(self.batch1.state, "in_progress")
- self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch1.id,
- "name": self.batch1.name,
- # we don't care in these tests, the 'find_batch' tests already
- # check this
- "weight": self.ANY,
- "pickings": self.ANY,
- },
- )
-
- def test_select_not_exists(self):
- """Select a draft that does not exist"""
- batch_id = self.batch1.id
- self.batch1.unlink()
- # Simulate the client selecting the batch in a list
- response = self.service.dispatch(
- "select", params={"picking_batch_id": batch_id}
- )
- self.assert_response(
- response,
- next_state="manual_selection",
- message={
- "message_type": "warning",
- "message": "This batch cannot be selected.",
- },
- data={"size": 0, "records": []},
- )
-
- def test_select_already_assigned(self):
- """Select a draft that does not exist"""
- self._add_stock_and_assign_pickings_for_batches(self.batch1)
- self.batch1.write(
- {"state": "in_progress", "user_id": self.env.ref("base.user_demo")}
- )
- # Simulate the client selecting the batch in a list
- response = self.service.dispatch(
- "select", params={"picking_batch_id": self.batch1.id}
- )
- self.assert_response(
- response,
- next_state="manual_selection",
- message={
- "message_type": "warning",
- "message": "This batch cannot be selected.",
- },
- data={"size": 0, "records": []},
- )
-
- def test_unassign_batch(self):
- """User cancels after selecting a batch, unassign it"""
- self._simulate_batch_selected(self.batch1)
- # Simulate the client selecting the batch in a list
- response = self.service.dispatch(
- "unassign", params={"picking_batch_id": self.batch1.id}
- )
- self.assertEqual(self.batch1.state, "draft")
- self.assertFalse(self.batch1.user_id)
- self.assert_response(response, next_state="start")
-
- def test_unassign_batch_not_exists(self):
- """User cancels after selecting a batch deleted meanwhile"""
- batch_id = self.batch1.id
- self.batch1.unlink()
- # Simulate the client selecting the batch in a list
- response = self.service.dispatch(
- "unassign", params={"picking_batch_id": batch_id}
- )
- self.assert_response(response, next_state="start")
-
-
-class ClusterPickingSelectedCase(ClusterPickingCommonCase):
- """Tests covering endpoints working on a single picking batch
-
- After a batch has been selected, by the tests covered in
- ``ClusterPickingSelectionCase``.
- """
-
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- cls.batch = cls._create_picking_batch(
- [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
- )
- cls._simulate_batch_selected(cls.batch, in_package=True)
-
- def test_confirm_start_ok(self):
- """User confirms she starts the selected picking batch (happy path)"""
- # batch1 was already selected, we only need to confirm the selection
- batch = self.batch
- self.assertEqual(batch.state, "in_progress")
- picking = batch.picking_ids[0]
- first_move_line = picking.move_line_ids[0]
- self.assertTrue(first_move_line)
- # A package exists on the move line, because the quant created
- # by ``_simulate_batch_selected`` has a package.
- package = first_move_line.package_id
- self.assertTrue(package)
-
- response = self.service.dispatch(
- "confirm_start", params={"picking_batch_id": self.batch.id}
- )
- self.assert_response(
- response,
- data={
- "id": first_move_line.id,
- "quantity": 1.0,
- "postponed": False,
- "location_dst": {
- "id": first_move_line.location_dest_id.id,
- "name": first_move_line.location_dest_id.name,
- },
- "location_src": {
- "id": first_move_line.location_id.id,
- "name": first_move_line.location_id.name,
- },
- "picking": {
- "id": picking.id,
- "name": picking.name,
- "note": "",
- "origin": picking.origin,
- },
- "batch": {"id": batch.id, "name": batch.name},
- "product": {
- "default_code": first_move_line.product_id.default_code,
- "display_name": first_move_line.product_id.display_name,
- "id": first_move_line.product_id.id,
- "name": first_move_line.product_id.name,
- "qty_available": first_move_line.product_id.qty_available,
- },
- "lot": None,
- "pack": {"id": package.id, "name": package.name},
- },
- next_state="start_line",
- )
-
- def test_confirm_start_not_exists(self):
- """User confirms she starts but batch has been deleted meanwhile"""
- batch_id = self.batch.id
- self.batch.unlink()
- response = self.service.dispatch(
- "confirm_start", params={"picking_batch_id": batch_id}
- )
- self.assert_response(
- response,
- message={
- "message_type": "error",
- "message": "This record you were working on does not exist anymore.",
- },
- next_state="start",
- )
-
- # TODO
- @unittest.skip("not sure yet what we have to do, keep for later")
- def test_confirm_start_all_is_done(self):
- """User confirms start but all lines are already done"""
- # we want to jump to the start because there are no lines
- # to process anymore, but we want to set pickings and
- # picking batch to done if not done yet (because the process
- # was interrupted for instance)
-
-
-class ClusterPickingLineCommonCase(ClusterPickingCommonCase):
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- # quants already existing are from demo data
- cls.env["stock.quant"].search(
- [("location_id", "=", cls.stock_location.id)]
- ).unlink()
- cls.batch = cls._create_picking_batch(
- [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
- )
-
- def _line_data(self, move_line, qty=1.0):
- # just force qty to 1.0
- return super()._line_data(move_line, qty=qty)
-
-
-class ClusterPickingScanLineCase(ClusterPickingLineCommonCase):
- """Tests covering the /scan_line endpoint
-
- After a batch has been selected and the user confirmed they are
- working on it.
-
- User scans something and the scan_line endpoints validates they
- scanned the proper thing to pick.
- """
-
- def _scan_line_ok(self, line, scanned):
- response = self.service.dispatch(
- "scan_line", params={"move_line_id": line.id, "barcode": scanned}
- )
- self.assert_response(
- response, next_state="scan_destination", data=self._line_data(line)
- )
-
- def _scan_line_error(self, line, scanned, message):
- response = self.service.dispatch(
- "scan_line", params={"move_line_id": line.id, "barcode": scanned}
- )
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(line),
- message=message,
- )
-
- def test_scan_line_pack_ok(self):
- """Scan to check if user picks the correct pack for current line"""
- self._simulate_batch_selected(self.batch, in_package=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.package_id.name)
-
- def test_scan_line_product_ok(self):
- """Scan to check if user picks the correct product for current line"""
- self._simulate_batch_selected(self.batch)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.product_id.barcode)
-
- def test_scan_line_lot_ok(self):
- """Scan to check if user picks the correct lot for current line"""
- self.product_a.tracking = "lot"
- self._simulate_batch_selected(self.batch, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.lot_id.name)
-
- def test_scan_line_serial_ok(self):
- """Scan to check if user picks the correct serial for current line"""
- self.product_a.tracking = "serial"
- self._simulate_batch_selected(self.batch, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.lot_id.name)
-
- def test_scan_line_error_product_tracked(self):
- """Scan a product tracked by lot, must scan the lot"""
- self.product_a.tracking = "lot"
- self._simulate_batch_selected(self.batch, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_error(
- line,
- line.product_id.barcode,
- {
- "message_type": "warning",
- "message": "Product tracked by lot, please scan one.",
- },
- )
-
- def test_scan_line_location_ok_single_package(self):
- """Scan to check if user scans a correct location for current line
-
- If there is only one single package in the location, there is no
- ambiguity so we can use it.
- """
- self._simulate_batch_selected(self.batch, in_package=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.location_id.barcode)
-
- def test_scan_line_location_ok_single_product(self):
- """Scan to check if user scans a correct location for current line
-
- If there is only one single product in the location, there is no
- ambiguity so we can use it.
- """
- self._simulate_batch_selected(self.batch)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.location_id.barcode)
-
- def test_scan_line_location_ok_single_lot(self):
- """Scan to check if user scans a correct location for current line
-
- If there is only one single lot in the location, there is no
- ambiguity so we can use it.
- """
- self._simulate_batch_selected(self.batch, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.location_id.barcode)
-
- def test_scan_line_location_error_several_package(self):
- """Scan to check if user scans a correct location for current line
-
- If there are several packages in the location, user has to scan one.
- """
- self._simulate_batch_selected(self.batch, in_package=True)
- line = self.batch.picking_ids.move_line_ids
- location = line.location_id
- # add a second package in the location
- self._update_qty_in_location(
- location,
- self.product_b,
- 10,
- package=self.env["stock.quant.package"].create({}),
- )
- self._scan_line_error(
- line,
- location.barcode,
- {
- "message_type": "warning",
- "message": "Several packages found in Stock, please scan a package.",
- },
- )
-
- def test_scan_line_location_error_several_products(self):
- """Scan to check if user scans a correct location for current line
-
- If there are several products in the location, user has to scan one.
- """
- self._simulate_batch_selected(self.batch)
- line = self.batch.picking_ids.move_line_ids
- location = line.location_id
- # add a second product in the location
- self._update_qty_in_location(location, self.product_b, 10)
- self._scan_line_error(
- line,
- location.barcode,
- {
- "message_type": "warning",
- "message": "Several products found in Stock, please scan a product.",
- },
- )
-
- def test_scan_line_location_error_several_lots(self):
- """Scan to check if user scans a correct location for current line
-
- If there are several lots in the location, user has to scan one.
- """
- self._simulate_batch_selected(self.batch, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- location = line.location_id
- lot = self.env["stock.production.lot"].create(
- {"product_id": self.product_a.id, "company_id": self.env.company.id}
- )
- # add a second lot in the location
- self._update_qty_in_location(location, self.product_a, 10, lot=lot)
- self._scan_line_error(
- line,
- location.barcode,
- {
- "message_type": "warning",
- "message": "Several lots found in Stock, please scan a lot.",
- },
- )
-
- def test_scan_line_error_not_found(self):
- """Nothing found for the barcode"""
- self._simulate_batch_selected(self.batch, in_package=True)
- self._scan_line_error(
- self.batch.picking_ids.move_line_ids,
- "NO_EXISTING_BARCODE",
- {"message_type": "error", "message": "Barcode not found"},
- )
-
-
-class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase):
- """Tests covering the /scan_destination_pack endpoint
-
- After a batch has been selected and the user confirmed they are
- working on it, user picked the good, now they scan the location
- destination.
- """
-
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- cls.batch = cls._create_picking_batch(
- [
- [
- cls.BatchProduct(product=cls.product_a, quantity=10),
- cls.BatchProduct(product=cls.product_b, quantity=10),
- ],
- [cls.BatchProduct(product=cls.product_a, quantity=10)],
- ]
- )
- cls.bin1 = cls.env["stock.quant.package"].create({})
-
- def test_scan_destination_pack_ok(self):
- """Happy path for scan destination package
-
- It sets the line in the pack for the full qty
- """
- self._simulate_batch_selected(self.batch)
- line = self.batch.picking_ids.move_line_ids[0]
- next_line = self.batch.picking_ids.move_line_ids[1]
- qty_done = line.product_uom_qty
- response = self.service.dispatch(
- "scan_destination_pack",
- params={
- "move_line_id": line.id,
- "barcode": self.bin1.name,
- "quantity": qty_done,
- },
- )
- self.assertRecordValues(
- line, [{"qty_done": qty_done, "result_package_id": self.bin1.id}]
- )
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(next_line),
- message={
- "message_type": "info",
- "message": "{} {} put in {}".format(
- line.qty_done, line.product_id.display_name, self.bin1.name
- ),
- },
- )
-
-
-# TODO tests for transitions to next line / no next lines, ...
-
-
-class ClusterPickingSkipLineCase(ClusterPickingCommonCase):
- """Tests covering the /skip_line endpoint
- """
-
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- # quants already existing are from demo data
- cls.env["stock.quant"].search(
- [("location_id", "=", cls.stock_location.id)]
- ).unlink()
- cls.batch = cls._create_picking_batch(
- [
- [
- cls.BatchProduct(product=cls.product_a, quantity=10),
- cls.BatchProduct(product=cls.product_b, quantity=20),
- ],
- [
- cls.BatchProduct(product=cls.product_a, quantity=30),
- cls.BatchProduct(product=cls.product_b, quantity=40),
- ],
- ]
- )
-
- def _skip_line(self, line, next_line=None):
- response = self.service.dispatch("skip_line", params={"move_line_id": line.id})
- if next_line:
- self.assert_response(
- response, next_state="start_line", data=self._line_data(next_line)
- )
- return response
-
- def test_skip_line(self):
- self._simulate_batch_selected(self.batch, in_package=True)
- lines = self.batch.picking_ids.move_line_ids
- # 1st line, next is 2nd
- self.assertFalse(lines[0].shopfloor_postponed)
- self._skip_line(lines[0], lines[1])
- self.assertTrue(lines[0].shopfloor_postponed)
- # 2nd line, next is 3rd
- self.assertFalse(lines[1].shopfloor_postponed)
- self._skip_line(lines[1], lines[2])
- self.assertTrue(lines[1].shopfloor_postponed)
- # 3rd line, next is 4th
- self.assertFalse(lines[2].shopfloor_postponed)
- self._skip_line(lines[2], lines[3])
- self.assertTrue(lines[2].shopfloor_postponed)
- # 4th line, next is 1st
- # the next line for the last one is the 1st,
- # because you'll have to process it anyway
- self.assertFalse(lines[3].shopfloor_postponed)
- self._skip_line(lines[3], lines[0])
- self.assertTrue(lines[3].shopfloor_postponed)
-
-
-class ClusterPickingUnloadingCommonCase(ClusterPickingCommonCase):
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- cls.batch = cls._create_picking_batch(
- [
- [
- cls.BatchProduct(product=cls.product_a, quantity=10),
- cls.BatchProduct(product=cls.product_b, quantity=10),
- ],
- [cls.BatchProduct(product=cls.product_a, quantity=10)],
- ]
- )
- cls._simulate_batch_selected(cls.batch)
- cls.bin1 = cls.env["stock.quant.package"].create({})
- cls.bin2 = cls.env["stock.quant.package"].create({})
- cls.packing_a_location = cls.env["stock.location"].create(
- {
- "name": "Packing A",
- "barcode": "Packing-A",
- "location_id": cls.packing_location.id,
- }
- )
- cls.packing_b_location = cls.env["stock.location"].create(
- {
- "name": "Packing B",
- "barcode": "Packing-B",
- "location_id": cls.packing_location.id,
- }
- )
-
- @classmethod
- def _set_dest_package_and_done(cls, move_lines, dest_package):
- """Simulate what would have been done in the previous steps"""
- for line in move_lines:
- line.write(
- {"qty_done": line.product_uom_qty, "result_package_id": dest_package.id}
- )
-
-
-class ClusterPickingPrepareUnloadCase(ClusterPickingUnloadingCommonCase):
- """Tests covering the /prepare_unload endpoint
-
- Destination packages have been set on all the move lines of the batch.
- The unload operation will start, but we have 2 paths for this:
-
- 1. unload all the destination packages at the same place
- 2. unload the destination packages one by one at different places
-
- By default, if all the move lines have the same destination, the
- first path is used. A flag on the batch picking keeps track of which
- path is used.
- """
-
- def test_prepare_unload_all_same_dest(self):
- """All move lines have the same destination location"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
- self._set_dest_package_and_done(move_lines[:2], self.bin1)
- self._set_dest_package_and_done(move_lines[2:], self.bin2)
- move_lines.write({"location_dest_id": self.packing_location.id})
- response = self.service.dispatch(
- "prepare_unload", params={"picking_batch_id": self.batch.id}
- )
- self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": True}])
- self.assert_response(
- response,
- next_state="unload_all",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "location_dst": {
- "id": self.packing_location.id,
- "name": self.packing_location.name,
- },
- },
- )
-
- def test_prepare_unload_different_dest(self):
- """All move lines have different destination locations"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
- self._set_dest_package_and_done(move_lines[:2], self.bin1)
- self._set_dest_package_and_done(move_lines[2:], self.bin2)
- move_lines[:1].write({"location_dest_id": self.packing_a_location.id})
- move_lines[:1].write({"location_dest_id": self.packing_b_location.id})
- response = self.service.dispatch(
- "prepare_unload", params={"picking_batch_id": self.batch.id}
- )
- self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
- first_line = move_lines[0]
- self.assert_response(
- response,
- next_state="unload_single",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
- "id": first_line.location_dest_id.id,
- "name": first_line.location_dest_id.name,
- },
- },
- )
-
-
-class ClusterPickingSetDestinationAllCase(ClusterPickingUnloadingCommonCase):
- """Tests covering the /set_destination_all endpoint
-
- All the picked lines go to the same destination, a single call to this
- endpoint set them as "unloaded" and set the destination. When the last
- available line of a picking is unloaded, the picking is set to 'done'.
- """
-
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- # this is what the /prepare_endpoint method would have set as all the
- # destinations are the same:
- cls.batch.cluster_picking_unload_all = True
-
- def test_set_destination_all_ok(self):
- """Set destination on all lines for the full batch and end the process"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
- # put destination packages, the whole quantity on lines and a similar
- # destination (when /set_destination_all is called, all the lines to
- # unload must have the same destination)
- self._set_dest_package_and_done(move_lines[:2], self.bin1)
- self._set_dest_package_and_done(move_lines[2:], self.bin2)
- move_lines.write({"location_dest_id": self.packing_location.id})
-
- response = self.service.dispatch(
- "set_destination_all",
- params={
- "picking_batch_id": self.batch.id,
- "barcode": self.packing_location.barcode,
- },
- )
- # since the whole batch is complete, we expect the batch and all
- # pickings to be 'done'
- self.assertRecordValues(
- move_lines.mapped("picking_id"), [{"state": "done"}, {"state": "done"}]
- )
- self.assertRecordValues(
- move_lines,
- [
- {
- "shopfloor_unloaded": True,
- "qty_done": 10,
- "state": "done",
- "location_dest_id": self.packing_location.id,
- },
- {
- "shopfloor_unloaded": True,
- "qty_done": 10,
- "state": "done",
- "location_dest_id": self.packing_location.id,
- },
- {
- "shopfloor_unloaded": True,
- "qty_done": 10,
- "state": "done",
- "location_dest_id": self.packing_location.id,
- },
- ],
- )
- self.assertRecordValues(self.batch, [{"state": "done"}])
- self.assert_response(
- response,
- next_state="start",
- message={"message_type": "info", "message": "Batch Transfer complete"},
- )
-
- def test_set_destination_all_remaining_lines(self):
- """Set destination on all lines for a part of the batch"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
- # Put destination packages, the whole quantity on lines and a similar
- # destination (when /set_destination_all is called, all the lines to
- # unload must have the same destination).
- # However, we keep a line without qty_done and destination package,
- # so when the dest location is set, the endpoint should route back
- # to the 'start_line' state to work on the remaining line.
- lines_to_unload = move_lines[:2]
- self._set_dest_package_and_done(lines_to_unload, self.bin1)
- lines_to_unload.write({"location_dest_id": self.packing_location.id})
-
- response = self.service.dispatch(
- "set_destination_all",
- params={
- "picking_batch_id": self.batch.id,
- "barcode": self.packing_location.barcode,
- },
- )
- # Since the whole batch is not complete, state should not be done.
- # The picking with one line should be "done" because we unloaded its line.
- # The second one still has a line to pick.
- one_line_picking = self.batch.picking_ids[0]
- two_lines_picking = self.batch.picking_ids[1]
- self.assertRecordValues(one_line_picking, [{"state": "done"}])
- self.assertRecordValues(two_lines_picking, [{"state": "assigned"}])
- self.assertRecordValues(
- move_lines,
- [
- {
- "shopfloor_unloaded": True,
- "qty_done": 10,
- "state": "done",
- "picking_id": one_line_picking.id,
- "location_dest_id": self.packing_location.id,
- },
- {
- "shopfloor_unloaded": True,
- "qty_done": 10,
- # will be done when the second line of the picking is unloaded
- "state": "assigned",
- "picking_id": two_lines_picking.id,
- "location_dest_id": self.packing_location.id,
- },
- {
- "shopfloor_unloaded": False,
- "qty_done": 0,
- "state": "assigned",
- "picking_id": two_lines_picking.id,
- "location_dest_id": self.packing_location.id,
- },
- ],
- )
- self.assertRecordValues(self.batch, [{"state": "in_progress"}])
-
- self.assert_response(
- # the remaining move line still needs to be picked
- response,
- next_state="start_line",
- data=self._line_data(move_lines[2]),
- )
-
- def test_set_destination_all_but_different_dest(self):
- """Endpoint was called but destinations are different"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
- self._set_dest_package_and_done(move_lines, self.bin1)
- move_lines[:2].write({"location_dest_id": self.packing_a_location.id})
- move_lines[2:].write({"location_dest_id": self.packing_b_location.id})
-
- response = self.service.dispatch(
- "set_destination_all",
- params={
- "picking_batch_id": self.batch.id,
- "barcode": self.packing_location.barcode,
- },
- )
- self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
- self.assert_response(
- response,
- next_state="unload_single",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
- "id": move_lines[0].location_dest_id.id,
- "name": move_lines[0].location_dest_id.name,
- },
- },
- )
-
- def test_set_destination_all_error_location_not_found(self):
- """Endpoint called with a barcode not existing for a location"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
- self._set_dest_package_and_done(move_lines, self.bin1)
- move_lines.write({"location_dest_id": self.packing_a_location.id})
-
- response = self.service.dispatch(
- "set_destination_all",
- params={"picking_batch_id": self.batch.id, "barcode": "NOTFOUND"},
- )
- self.assert_response(
- response,
- next_state="unload_all",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "location_dst": {
- "id": move_lines[0].location_dest_id.id,
- "name": move_lines[0].location_dest_id.name,
- },
- },
- message={
- "message_type": "error",
- "message": "No location found for this barcode.",
- },
- )
-
- def test_set_destination_all_error_location_invalid(self):
- """Endpoint called with a barcode for an invalid location
-
- It is invalid when the location is not the destination location or
- sublocation of the picking type.
- """
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
- self._set_dest_package_and_done(move_lines, self.bin1)
- move_lines.write({"location_dest_id": self.packing_a_location.id})
-
- response = self.service.dispatch(
- "set_destination_all",
- params={
- "picking_batch_id": self.batch.id,
- "barcode": self.dispatch_location.barcode,
- },
- )
- self.assert_response(
- response,
- next_state="unload_all",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "location_dst": {
- "id": move_lines[0].location_dest_id.id,
- "name": move_lines[0].location_dest_id.name,
- },
- },
- message={"message_type": "error", "message": "You cannot place it here"},
- )
-
- def test_set_destination_all_need_confirmation(self):
- """Endpoint called with a barcode for another (valid) location"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
- self._set_dest_package_and_done(move_lines, self.bin1)
- move_lines.write({"location_dest_id": self.packing_a_location.id})
-
- response = self.service.dispatch(
- "set_destination_all",
- params={
- "picking_batch_id": self.batch.id,
- "barcode": self.packing_b_location.barcode,
- },
- )
- self.assert_response(
- response,
- next_state="confirm_unload_all",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "location_dst": {
- "id": move_lines[0].location_dest_id.id,
- "name": move_lines[0].location_dest_id.name,
- },
- },
- )
-
- def test_set_destination_all_with_confirmation(self):
- """Endpoint called with a barcode for another (valid) location, confirm"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
- self._set_dest_package_and_done(move_lines, self.bin1)
- move_lines.write({"location_dest_id": self.packing_a_location.id})
-
- response = self.service.dispatch(
- "set_destination_all",
- params={
- "picking_batch_id": self.batch.id,
- "barcode": self.packing_b_location.barcode,
- "confirmation": True,
- },
- )
- self.assertRecordValues(
- move_lines,
- [
- {"location_dest_id": self.packing_b_location.id},
- {"location_dest_id": self.packing_b_location.id},
- {"location_dest_id": self.packing_b_location.id},
- ],
- )
- self.assert_response(
- response,
- next_state="start",
- message={"message_type": "info", "message": "Batch Transfer complete"},
- )
-
-
-class ClusterPickingUnloadSplitCase(ClusterPickingUnloadingCommonCase):
- """Tests covering the /unload_split endpoint
-
- All the destinations of the bins were the same so the "unload all" screen
- was presented to the user, but they want different destination, so they hit
- the "split" button. From now on, the workflow should use the "unload single"
- screen even if the destinations are the same.
- """
-
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- # this is what the /prepare_endpoint method would have set as all the
- # destinations are the same:
- cls.batch.cluster_picking_unload_all = True
-
- def test_unload_split_ok(self):
- """Call /unload_split and continue to unload single"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
- # put destination packages, the whole quantity on lines and a similar
- # destination (when /set_destination_all is called, all the lines to
- # unload must have the same destination)
- self._set_dest_package_and_done(move_lines, self.bin1)
- move_lines.write({"location_dest_id": self.packing_location.id})
-
- response = self.service.dispatch(
- "unload_split", params={"picking_batch_id": self.batch.id}
- )
- self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
- self.assert_response(
- # the remaining move line still needs to be picked
- response,
- next_state="unload_single",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
- "id": move_lines[0].location_dest_id.id,
- "name": move_lines[0].location_dest_id.name,
- },
- },
- )
-
-
-class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase):
- """Tests covering the /unload_scan_pack endpoint
-
- Goods have been put in the package bins, they have different destinations
- or /unload_split has been called, now user has to unload package per
- package. For this, they'll first scan the bin package, which will call the
- endpoint /unload_scan_pack. (second step will be to set the destination
- with /unload_scan_destination, in a different test case)
- """
-
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- cls.batch.cluster_picking_unload_all = False
- cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids")
- cls._set_dest_package_and_done(cls.move_lines, cls.bin1)
- cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id})
- cls.move_lines[2:].write({"location_dest_id": cls.packing_b_location.id})
-
- def test_unload_scan_pack_ok(self):
- """Endpoint /unload_scan_pack is called, result ok"""
- response = self.service.dispatch(
- "unload_scan_pack",
- params={
- "picking_batch_id": self.batch.id,
- "package_id": self.bin1.id,
- "barcode": self.bin1.name,
- },
- )
- self.assert_response(
- response,
- next_state="unload_set_destination",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
- "id": self.move_lines[0].location_dest_id.id,
- "name": self.move_lines[0].location_dest_id.name,
- },
- },
- )
-
- def test_unload_scan_pack_wrong_barcode(self):
- """Endpoint /unload_scan_pack is called, wrong barcode scanned"""
- response = self.service.dispatch(
- "unload_scan_pack",
- params={
- "picking_batch_id": self.batch.id,
- "package_id": self.bin1.id,
- "barcode": self.bin2.name,
- },
- )
- self.assert_response(
- response,
- next_state="unload_single",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
- "id": self.move_lines[0].location_dest_id.id,
- "name": self.move_lines[0].location_dest_id.name,
- },
- },
- message={"message_type": "error", "message": "Wrong bin"},
- )
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
new file mode 100644
index 0000000000..02574d89b4
--- /dev/null
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -0,0 +1,116 @@
+from .common import CommonCase, PickingBatchMixin
+
+
+class ClusterPickingCommonCase(CommonCase, PickingBatchMixin):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.product_a = cls.env["product.product"].create(
+ {
+ "name": "Product A",
+ "type": "product",
+ "default_code": "A",
+ "barcode": "A",
+ }
+ )
+ cls.product_b = cls.env["product.product"].create(
+ {
+ "name": "Product B",
+ "type": "product",
+ "default_code": "B",
+ "barcode": "B",
+ }
+ )
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
+ cls.process = cls.menu.process_id
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.wh.delivery_steps = "pick_pack_ship"
+ cls.picking_type = cls.process.picking_type_id
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
+ self.service = work.component(usage="cluster_picking")
+
+ @classmethod
+ def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False):
+ """Create a state as if a batch was selected by the user
+
+ * The picking batch is in progress
+ * It is assigned to the current user
+ * All the move lines are available
+
+ Note: currently, this method create a source package that contains
+ all the products of the batch. It is enough for the current tests.
+ """
+ pickings = batches.mapped("picking_ids")
+ cls._fill_stock_for_moves(
+ pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot
+ )
+ pickings.action_assign()
+ batches.write({"state": "in_progress", "user_id": cls.env.uid})
+
+ def _line_data(self, move_line, qty=None):
+ picking = move_line.picking_id
+ batch = picking.batch_id
+ # A package exists on the move line, because the quant created
+ # by ``_simulate_batch_selected`` has a package.
+ package = move_line.package_id
+ lot = move_line.lot_id
+ return {
+ "id": move_line.id,
+ "quantity": qty or move_line.product_uom_qty,
+ "postponed": move_line.shopfloor_postponed,
+ "location_dst": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "note": "",
+ "origin": picking.origin,
+ },
+ "batch": {"id": batch.id, "name": batch.name},
+ "product": {
+ "default_code": move_line.product_id.default_code,
+ "display_name": move_line.product_id.display_name,
+ "id": move_line.product_id.id,
+ "name": move_line.product_id.name,
+ "qty_available": move_line.product_id.qty_available,
+ },
+ "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""}
+ if lot
+ else None,
+ "pack": {"id": package.id, "name": package.name} if package else None,
+ }
+
+
+class ClusterPickingAPICase(ClusterPickingCommonCase):
+ """Base tests for the cluster picking API"""
+
+ def test_to_openapi(self):
+ # will raise if it fails to generate the openapi specs
+ self.service.to_openapi()
+
+
+class ClusterPickingLineCommonCase(ClusterPickingCommonCase):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # quants already existing are from demo data
+ cls.env["stock.quant"].search(
+ [("location_id", "=", cls.stock_location.id)]
+ ).unlink()
+ cls.batch = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+
+ def _line_data(self, move_line, qty=1.0):
+ # just force qty to 1.0
+ return super()._line_data(move_line, qty=qty)
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
new file mode 100644
index 0000000000..38f0b7a40b
--- /dev/null
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -0,0 +1,236 @@
+from .test_cluster_picking_base import (
+ ClusterPickingCommonCase,
+ ClusterPickingLineCommonCase,
+)
+
+
+class ClusterPickingScanLineCase(ClusterPickingLineCommonCase):
+ """Tests covering the /scan_line endpoint
+
+ After a batch has been selected and the user confirmed they are
+ working on it.
+
+ User scans something and the scan_line endpoints validates they
+ scanned the proper thing to pick.
+ """
+
+ def _scan_line_ok(self, line, scanned):
+ response = self.service.dispatch(
+ "scan_line", params={"move_line_id": line.id, "barcode": scanned}
+ )
+ self.assert_response(
+ response, next_state="scan_destination", data=self._line_data(line)
+ )
+
+ def _scan_line_error(self, line, scanned, message):
+ response = self.service.dispatch(
+ "scan_line", params={"move_line_id": line.id, "barcode": scanned}
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(line),
+ message=message,
+ )
+
+ def test_scan_line_pack_ok(self):
+ """Scan to check if user picks the correct pack for current line"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.package_id.name)
+
+ def test_scan_line_product_ok(self):
+ """Scan to check if user picks the correct product for current line"""
+ self._simulate_batch_selected(self.batch)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.product_id.barcode)
+
+ def test_scan_line_lot_ok(self):
+ """Scan to check if user picks the correct lot for current line"""
+ self.product_a.tracking = "lot"
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.lot_id.name)
+
+ def test_scan_line_serial_ok(self):
+ """Scan to check if user picks the correct serial for current line"""
+ self.product_a.tracking = "serial"
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.lot_id.name)
+
+ def test_scan_line_error_product_tracked(self):
+ """Scan a product tracked by lot, must scan the lot"""
+ self.product_a.tracking = "lot"
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_error(
+ line,
+ line.product_id.barcode,
+ {
+ "message_type": "warning",
+ "message": "Product tracked by lot, please scan one.",
+ },
+ )
+
+ def test_scan_line_location_ok_single_package(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there is only one single package in the location, there is no
+ ambiguity so we can use it.
+ """
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.location_id.barcode)
+
+ def test_scan_line_location_ok_single_product(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there is only one single product in the location, there is no
+ ambiguity so we can use it.
+ """
+ self._simulate_batch_selected(self.batch)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.location_id.barcode)
+
+ def test_scan_line_location_ok_single_lot(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there is only one single lot in the location, there is no
+ ambiguity so we can use it.
+ """
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.location_id.barcode)
+
+ def test_scan_line_location_error_several_package(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there are several packages in the location, user has to scan one.
+ """
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ location = line.location_id
+ # add a second package in the location
+ self._update_qty_in_location(
+ location,
+ self.product_b,
+ 10,
+ package=self.env["stock.quant.package"].create({}),
+ )
+ self._scan_line_error(
+ line,
+ location.barcode,
+ {
+ "message_type": "warning",
+ "message": "Several packages found in Stock, please scan a package.",
+ },
+ )
+
+ def test_scan_line_location_error_several_products(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there are several products in the location, user has to scan one.
+ """
+ self._simulate_batch_selected(self.batch)
+ line = self.batch.picking_ids.move_line_ids
+ location = line.location_id
+ # add a second product in the location
+ self._update_qty_in_location(location, self.product_b, 10)
+ self._scan_line_error(
+ line,
+ location.barcode,
+ {
+ "message_type": "warning",
+ "message": "Several products found in Stock, please scan a product.",
+ },
+ )
+
+ def test_scan_line_location_error_several_lots(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there are several lots in the location, user has to scan one.
+ """
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ location = line.location_id
+ lot = self.env["stock.production.lot"].create(
+ {"product_id": self.product_a.id, "company_id": self.env.company.id}
+ )
+ # add a second lot in the location
+ self._update_qty_in_location(location, self.product_a, 10, lot=lot)
+ self._scan_line_error(
+ line,
+ location.barcode,
+ {
+ "message_type": "warning",
+ "message": "Several lots found in Stock, please scan a lot.",
+ },
+ )
+
+ def test_scan_line_error_not_found(self):
+ """Nothing found for the barcode"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ self._scan_line_error(
+ self.batch.picking_ids.move_line_ids,
+ "NO_EXISTING_BARCODE",
+ {"message_type": "error", "message": "Barcode not found"},
+ )
+
+
+class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase):
+ """Tests covering the /scan_destination_pack endpoint
+
+ After a batch has been selected and the user confirmed they are
+ working on it, user picked the good, now they scan the location
+ destination.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.batch = cls._create_picking_batch(
+ [
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10),
+ cls.BatchProduct(product=cls.product_b, quantity=10),
+ ],
+ [cls.BatchProduct(product=cls.product_a, quantity=10)],
+ ]
+ )
+ cls.bin1 = cls.env["stock.quant.package"].create({})
+
+ def test_scan_destination_pack_ok(self):
+ """Happy path for scan destination package
+
+ It sets the line in the pack for the full qty
+ """
+ self._simulate_batch_selected(self.batch)
+ line = self.batch.picking_ids.move_line_ids[0]
+ next_line = self.batch.picking_ids.move_line_ids[1]
+ qty_done = line.product_uom_qty
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "move_line_id": line.id,
+ "barcode": self.bin1.name,
+ "quantity": qty_done,
+ },
+ )
+ self.assertRecordValues(
+ line, [{"qty_done": qty_done, "result_package_id": self.bin1.id}]
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(next_line),
+ message={
+ "message_type": "info",
+ "message": "{} {} put in {}".format(
+ line.qty_done, line.product_id.display_name, self.bin1.name
+ ),
+ },
+ )
+
+
+# TODO tests for transitions to next line / no next lines, ...
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
new file mode 100644
index 0000000000..f8e8b0d5dc
--- /dev/null
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -0,0 +1,428 @@
+import unittest
+
+from .test_cluster_picking_base import ClusterPickingCommonCase
+
+
+class ClusterPickingSelectionCase(ClusterPickingCommonCase):
+ """Tests covering the selection of picking batches
+
+ Endpoints:
+
+ * /cluster_picking/find_batch
+ * /cluster_picking/list_batch
+ * /cluster_picking/select
+ * /cluster_picking/unassign
+
+ These endpoints interact with a list of picking batches.
+ The other endpoints that interact with a single batch (after selection)
+ are handled in other classes.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # drop base demo data and create our own batches to work with
+ cls.env["stock.picking.batch"].search([]).unlink()
+ cls.batch1 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+ cls.batch2 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+ cls.batch3 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+ cls.batch4 = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+
+ def _add_stock_and_assign_pickings_for_batches(self, batches):
+ pickings = batches.mapped("picking_ids")
+ self._fill_stock_for_moves(pickings.mapped("move_lines"))
+ pickings.action_assign()
+
+ def test_find_batch_in_progress_current_user(self):
+ """Find an in-progress batch assigned to the current user"""
+ # Simulate the client asking a batch by clicking on "get work"
+ self._add_stock_and_assign_pickings_for_batches(
+ self.batch1 | self.batch2 | self.batch3
+ )
+ self.batch3.user_id = self.env.uid
+ self.batch3.confirm_picking() # set to in progress
+ response = self.service.dispatch("find_batch")
+
+ # we expect to find batch 3 as it's assigned to the current
+ # user and in progress (first priority)
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch3.id,
+ "name": self.batch3.name,
+ # TODO
+ "weight": 0,
+ "pickings": [
+ {
+ "id": self.batch3.picking_ids.id,
+ "name": self.batch3.picking_ids.name,
+ "move_line_count": len(self.batch3.picking_ids.move_line_ids),
+ "origin": self.batch3.picking_ids.origin,
+ "partner": {
+ "id": self.batch3.picking_ids.partner_id.id,
+ "name": self.batch3.picking_ids.partner_id.name,
+ },
+ }
+ ],
+ },
+ )
+
+ def test_find_batch_assigned(self):
+ """Find a draft batch assigned to the current user"""
+ # batches must have all their pickings available to be selected
+ self._add_stock_and_assign_pickings_for_batches(
+ self.batch1 | self.batch2 | self.batch3
+ )
+ # batch2 in draft but assigned to the current user should be
+ # selected before the others
+ self.batch2.user_id = self.env.uid
+ response = self.service.dispatch("find_batch")
+
+ # The endpoint starts the batch
+ self.assertEqual(self.batch2.state, "in_progress")
+
+ # we expect to find batch 2 as it's assigned to the current user
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch2.id,
+ "name": self.batch2.name,
+ # TODO
+ "weight": 0,
+ "pickings": [
+ {
+ "id": self.batch2.picking_ids.id,
+ "name": self.batch2.picking_ids.name,
+ "move_line_count": len(self.batch2.picking_ids.move_line_ids),
+ "origin": self.batch2.picking_ids.origin,
+ "partner": {
+ "id": self.batch2.picking_ids.partner_id.id,
+ "name": self.batch2.picking_ids.partner_id.name,
+ },
+ }
+ ],
+ },
+ )
+
+ def test_find_batch_unassigned_draft(self):
+ """Find a draft batch"""
+ # batches must have all their pickings available to be selected
+ self._add_stock_and_assign_pickings_for_batches(self.batch2 | self.batch3)
+ # batch1 has not all pickings available, so the first draft
+ # is batch2, should be selected
+ response = self.service.dispatch("find_batch")
+
+ # The endpoint starts the batch and assign it to self
+ self.assertEqual(self.batch2.user_id, self.env.user)
+ self.assertEqual(self.batch2.state, "in_progress")
+
+ # we expect to find batch 2 as it's the first one with all pickings
+ # available
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch2.id,
+ "name": self.batch2.name,
+ # TODO
+ "weight": 0,
+ "pickings": [
+ {
+ "id": self.batch2.picking_ids.id,
+ "name": self.batch2.picking_ids.name,
+ "move_line_count": len(self.batch2.picking_ids.move_line_ids),
+ "origin": self.batch2.picking_ids.origin,
+ "partner": {
+ "id": self.batch2.picking_ids.partner_id.id,
+ "name": self.batch2.picking_ids.partner_id.name,
+ },
+ }
+ ],
+ },
+ )
+
+ def test_find_batch_not_found(self):
+ """No batch to work on"""
+ # No batch match the rules to work on them, because
+ # their pickings are not available
+ response = self.service.dispatch("find_batch")
+
+ self.assert_response(
+ response,
+ next_state="start",
+ message={
+ "message_type": "info",
+ "message": "No more work to do, please create a new batch transfer",
+ },
+ )
+
+ def test_list_batch(self):
+ """List all available batches"""
+ # batches must have all their pickings available to be selected
+ self._add_stock_and_assign_pickings_for_batches(
+ self.batch1 | self.batch2 | self.batch3
+ )
+ self.batch1.write({"state": "in_progress", "user_id": self.env.uid})
+ self.batch2.write(
+ {"state": "in_progress", "user_id": self.env.ref("base.user_demo")}
+ )
+ self.batch3.write({"state": "draft", "user_id": False})
+
+ self.assertEqual(
+ self.env["stock.picking.batch"].search([]),
+ self.batch1 + self.batch2 + self.batch3 + self.batch4,
+ )
+ # Simulate the client asking the list of batches
+ response = self.service.dispatch("list_batch")
+ self.assert_response(
+ response,
+ next_state="manual_selection",
+ data={
+ "size": 2,
+ "records": [
+ {
+ "id": self.batch1.id,
+ "name": self.batch1.name,
+ "picking_count": 1,
+ "move_line_count": 1,
+ },
+ # batch 2 is excluded because assigned to someone else
+ {
+ "id": self.batch3.id,
+ "name": self.batch3.name,
+ "picking_count": 1,
+ "move_line_count": 1,
+ },
+ # batch 4 is excluded because not all of its pickings are
+ # assigned
+ ],
+ },
+ )
+
+ def test_select_in_progress_assigned(self):
+ """Select an in-progress batch assigned to the current user"""
+ self._add_stock_and_assign_pickings_for_batches(self.batch1)
+ self.batch1.write({"state": "in_progress", "user_id": self.env.uid})
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "select", params={"picking_batch_id": self.batch1.id}
+ )
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch1.id,
+ "name": self.batch1.name,
+ # we don't care in these tests, the 'find_batch' tests already
+ # check this
+ "weight": self.ANY,
+ "pickings": self.ANY,
+ },
+ )
+
+ def test_select_draft_assigned(self):
+ """Select a draft batch assigned to the current user"""
+ self._add_stock_and_assign_pickings_for_batches(self.batch1)
+ self.batch1.write({"user_id": self.env.uid})
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "select", params={"picking_batch_id": self.batch1.id}
+ )
+ # The endpoint starts the batch and assign it to self
+ self.assertEqual(self.batch1.user_id, self.env.user)
+ self.assertEqual(self.batch1.state, "in_progress")
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch1.id,
+ "name": self.batch1.name,
+ # we don't care in these tests, the 'find_batch' tests already
+ # check this
+ "weight": self.ANY,
+ "pickings": self.ANY,
+ },
+ )
+
+ def test_select_draft_unassigned(self):
+ """Select a draft batch not assigned to a user"""
+ self._add_stock_and_assign_pickings_for_batches(self.batch1)
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "select", params={"picking_batch_id": self.batch1.id}
+ )
+ # The endpoint starts the batch and assign it to self
+ self.assertEqual(self.batch1.user_id, self.env.user)
+ self.assertEqual(self.batch1.state, "in_progress")
+ self.assert_response(
+ response,
+ next_state="confirm_start",
+ data={
+ "id": self.batch1.id,
+ "name": self.batch1.name,
+ # we don't care in these tests, the 'find_batch' tests already
+ # check this
+ "weight": self.ANY,
+ "pickings": self.ANY,
+ },
+ )
+
+ def test_select_not_exists(self):
+ """Select a draft that does not exist"""
+ batch_id = self.batch1.id
+ self.batch1.unlink()
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "select", params={"picking_batch_id": batch_id}
+ )
+ self.assert_response(
+ response,
+ next_state="manual_selection",
+ message={
+ "message_type": "warning",
+ "message": "This batch cannot be selected.",
+ },
+ data={"size": 0, "records": []},
+ )
+
+ def test_select_already_assigned(self):
+ """Select a draft that does not exist"""
+ self._add_stock_and_assign_pickings_for_batches(self.batch1)
+ self.batch1.write(
+ {"state": "in_progress", "user_id": self.env.ref("base.user_demo")}
+ )
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "select", params={"picking_batch_id": self.batch1.id}
+ )
+ self.assert_response(
+ response,
+ next_state="manual_selection",
+ message={
+ "message_type": "warning",
+ "message": "This batch cannot be selected.",
+ },
+ data={"size": 0, "records": []},
+ )
+
+ def test_unassign_batch(self):
+ """User cancels after selecting a batch, unassign it"""
+ self._simulate_batch_selected(self.batch1)
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "unassign", params={"picking_batch_id": self.batch1.id}
+ )
+ self.assertEqual(self.batch1.state, "draft")
+ self.assertFalse(self.batch1.user_id)
+ self.assert_response(response, next_state="start")
+
+ def test_unassign_batch_not_exists(self):
+ """User cancels after selecting a batch deleted meanwhile"""
+ batch_id = self.batch1.id
+ self.batch1.unlink()
+ # Simulate the client selecting the batch in a list
+ response = self.service.dispatch(
+ "unassign", params={"picking_batch_id": batch_id}
+ )
+ self.assert_response(response, next_state="start")
+
+
+class ClusterPickingSelectedCase(ClusterPickingCommonCase):
+ """Tests covering endpoints working on a single picking batch
+
+ After a batch has been selected, by the tests covered in
+ ``ClusterPickingSelectionCase``.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.batch = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ )
+ cls._simulate_batch_selected(cls.batch, in_package=True)
+
+ def test_confirm_start_ok(self):
+ """User confirms she starts the selected picking batch (happy path)"""
+ # batch1 was already selected, we only need to confirm the selection
+ batch = self.batch
+ self.assertEqual(batch.state, "in_progress")
+ picking = batch.picking_ids[0]
+ first_move_line = picking.move_line_ids[0]
+ self.assertTrue(first_move_line)
+ # A package exists on the move line, because the quant created
+ # by ``_simulate_batch_selected`` has a package.
+ package = first_move_line.package_id
+ self.assertTrue(package)
+
+ response = self.service.dispatch(
+ "confirm_start", params={"picking_batch_id": self.batch.id}
+ )
+ self.assert_response(
+ response,
+ data={
+ "id": first_move_line.id,
+ "quantity": 1.0,
+ "postponed": False,
+ "location_dst": {
+ "id": first_move_line.location_dest_id.id,
+ "name": first_move_line.location_dest_id.name,
+ },
+ "location_src": {
+ "id": first_move_line.location_id.id,
+ "name": first_move_line.location_id.name,
+ },
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "note": "",
+ "origin": picking.origin,
+ },
+ "batch": {"id": batch.id, "name": batch.name},
+ "product": {
+ "default_code": first_move_line.product_id.default_code,
+ "display_name": first_move_line.product_id.display_name,
+ "id": first_move_line.product_id.id,
+ "name": first_move_line.product_id.name,
+ "qty_available": first_move_line.product_id.qty_available,
+ },
+ "lot": None,
+ "pack": {"id": package.id, "name": package.name},
+ },
+ next_state="start_line",
+ )
+
+ def test_confirm_start_not_exists(self):
+ """User confirms she starts but batch has been deleted meanwhile"""
+ batch_id = self.batch.id
+ self.batch.unlink()
+ response = self.service.dispatch(
+ "confirm_start", params={"picking_batch_id": batch_id}
+ )
+ self.assert_response(
+ response,
+ message={
+ "message_type": "error",
+ "message": "This record you were working on does not exist anymore.",
+ },
+ next_state="start",
+ )
+
+ # TODO
+ @unittest.skip("not sure yet what we have to do, keep for later")
+ def test_confirm_start_all_is_done(self):
+ """User confirms start but all lines are already done"""
+ # we want to jump to the start because there are no lines
+ # to process anymore, but we want to set pickings and
+ # picking batch to done if not done yet (because the process
+ # was interrupted for instance)
diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py
new file mode 100644
index 0000000000..a2d83d1a6c
--- /dev/null
+++ b/shopfloor/tests/test_cluster_picking_skip.py
@@ -0,0 +1,59 @@
+from .test_cluster_picking_base import ClusterPickingCommonCase
+
+
+class ClusterPickingSkipLineCase(ClusterPickingCommonCase):
+ """Tests covering the /skip_line endpoint
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # quants already existing are from demo data
+ cls.env["stock.quant"].search(
+ [("location_id", "=", cls.stock_location.id)]
+ ).unlink()
+ cls.batch = cls._create_picking_batch(
+ [
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10),
+ cls.BatchProduct(product=cls.product_b, quantity=20),
+ ],
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=30),
+ cls.BatchProduct(product=cls.product_b, quantity=40),
+ ],
+ ]
+ )
+
+ def _skip_line(self, line, next_line=None):
+ response = self.service.dispatch("skip_line", params={"move_line_id": line.id})
+ if next_line:
+ self.assert_response(
+ response, next_state="start_line", data=self._line_data(next_line)
+ )
+ return response
+
+ def test_skip_line(self):
+ self._simulate_batch_selected(self.batch, in_package=True)
+ lines = self.batch.picking_ids.move_line_ids
+ # 1st line, next is 2nd
+ self.assertFalse(lines[0].shopfloor_postponed)
+ self._skip_line(lines[0], lines[1])
+ self.assertTrue(lines[0].shopfloor_postponed)
+ # 2nd line, next is 3rd
+ self.assertFalse(lines[1].shopfloor_postponed)
+ self._skip_line(lines[1], lines[2])
+ self.assertTrue(lines[1].shopfloor_postponed)
+ # 3rd line, next is 4th
+ self.assertFalse(lines[2].shopfloor_postponed)
+ self._skip_line(lines[2], lines[3])
+ self.assertTrue(lines[2].shopfloor_postponed)
+ # 4th line, next is 1st
+ # the next line for the last one is the 1st,
+ # because you'll have to process it anyway
+ self.assertFalse(lines[3].shopfloor_postponed)
+ self._skip_line(lines[3], lines[0])
+ self.assertTrue(lines[3].shopfloor_postponed)
+
+
+# TODO tests for transitions to next line / no next lines, ...
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
new file mode 100644
index 0000000000..5fc13b3137
--- /dev/null
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -0,0 +1,491 @@
+from .test_cluster_picking_base import ClusterPickingCommonCase
+
+
+class ClusterPickingUnloadingCommonCase(ClusterPickingCommonCase):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.batch = cls._create_picking_batch(
+ [
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10),
+ cls.BatchProduct(product=cls.product_b, quantity=10),
+ ],
+ [cls.BatchProduct(product=cls.product_a, quantity=10)],
+ ]
+ )
+ cls._simulate_batch_selected(cls.batch)
+ cls.bin1 = cls.env["stock.quant.package"].create({})
+ cls.bin2 = cls.env["stock.quant.package"].create({})
+ cls.packing_a_location = cls.env["stock.location"].create(
+ {
+ "name": "Packing A",
+ "barcode": "Packing-A",
+ "location_id": cls.packing_location.id,
+ }
+ )
+ cls.packing_b_location = cls.env["stock.location"].create(
+ {
+ "name": "Packing B",
+ "barcode": "Packing-B",
+ "location_id": cls.packing_location.id,
+ }
+ )
+
+ @classmethod
+ def _set_dest_package_and_done(cls, move_lines, dest_package):
+ """Simulate what would have been done in the previous steps"""
+ for line in move_lines:
+ line.write(
+ {"qty_done": line.product_uom_qty, "result_package_id": dest_package.id}
+ )
+
+
+class ClusterPickingPrepareUnloadCase(ClusterPickingUnloadingCommonCase):
+ """Tests covering the /prepare_unload endpoint
+
+ Destination packages have been set on all the move lines of the batch.
+ The unload operation will start, but we have 2 paths for this:
+
+ 1. unload all the destination packages at the same place
+ 2. unload the destination packages one by one at different places
+
+ By default, if all the move lines have the same destination, the
+ first path is used. A flag on the batch picking keeps track of which
+ path is used.
+ """
+
+ def test_prepare_unload_all_same_dest(self):
+ """All move lines have the same destination location"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines[:2], self.bin1)
+ self._set_dest_package_and_done(move_lines[2:], self.bin2)
+ move_lines.write({"location_dest_id": self.packing_location.id})
+ response = self.service.dispatch(
+ "prepare_unload", params={"picking_batch_id": self.batch.id}
+ )
+ self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": True}])
+ self.assert_response(
+ response,
+ next_state="unload_all",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "location_dst": {
+ "id": self.packing_location.id,
+ "name": self.packing_location.name,
+ },
+ },
+ )
+
+ def test_prepare_unload_different_dest(self):
+ """All move lines have different destination locations"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines[:2], self.bin1)
+ self._set_dest_package_and_done(move_lines[2:], self.bin2)
+ move_lines[:1].write({"location_dest_id": self.packing_a_location.id})
+ move_lines[:1].write({"location_dest_id": self.packing_b_location.id})
+ response = self.service.dispatch(
+ "prepare_unload", params={"picking_batch_id": self.batch.id}
+ )
+ self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
+ first_line = move_lines[0]
+ self.assert_response(
+ response,
+ next_state="unload_single",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
+ "location_dst": {
+ "id": first_line.location_dest_id.id,
+ "name": first_line.location_dest_id.name,
+ },
+ },
+ )
+
+
+class ClusterPickingSetDestinationAllCase(ClusterPickingUnloadingCommonCase):
+ """Tests covering the /set_destination_all endpoint
+
+ All the picked lines go to the same destination, a single call to this
+ endpoint set them as "unloaded" and set the destination. When the last
+ available line of a picking is unloaded, the picking is set to 'done'.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # this is what the /prepare_endpoint method would have set as all the
+ # destinations are the same:
+ cls.batch.cluster_picking_unload_all = True
+
+ def test_set_destination_all_ok(self):
+ """Set destination on all lines for the full batch and end the process"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ # put destination packages, the whole quantity on lines and a similar
+ # destination (when /set_destination_all is called, all the lines to
+ # unload must have the same destination)
+ self._set_dest_package_and_done(move_lines[:2], self.bin1)
+ self._set_dest_package_and_done(move_lines[2:], self.bin2)
+ move_lines.write({"location_dest_id": self.packing_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ # since the whole batch is complete, we expect the batch and all
+ # pickings to be 'done'
+ self.assertRecordValues(
+ move_lines.mapped("picking_id"), [{"state": "done"}, {"state": "done"}]
+ )
+ self.assertRecordValues(
+ move_lines,
+ [
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ "state": "done",
+ "location_dest_id": self.packing_location.id,
+ },
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ "state": "done",
+ "location_dest_id": self.packing_location.id,
+ },
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ "state": "done",
+ "location_dest_id": self.packing_location.id,
+ },
+ ],
+ )
+ self.assertRecordValues(self.batch, [{"state": "done"}])
+ self.assert_response(
+ response,
+ next_state="start",
+ message={"message_type": "info", "message": "Batch Transfer complete"},
+ )
+
+ def test_set_destination_all_remaining_lines(self):
+ """Set destination on all lines for a part of the batch"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ # Put destination packages, the whole quantity on lines and a similar
+ # destination (when /set_destination_all is called, all the lines to
+ # unload must have the same destination).
+ # However, we keep a line without qty_done and destination package,
+ # so when the dest location is set, the endpoint should route back
+ # to the 'start_line' state to work on the remaining line.
+ lines_to_unload = move_lines[:2]
+ self._set_dest_package_and_done(lines_to_unload, self.bin1)
+ lines_to_unload.write({"location_dest_id": self.packing_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ # Since the whole batch is not complete, state should not be done.
+ # The picking with one line should be "done" because we unloaded its line.
+ # The second one still has a line to pick.
+ one_line_picking = self.batch.picking_ids[0]
+ two_lines_picking = self.batch.picking_ids[1]
+ self.assertRecordValues(one_line_picking, [{"state": "done"}])
+ self.assertRecordValues(two_lines_picking, [{"state": "assigned"}])
+ self.assertRecordValues(
+ move_lines,
+ [
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ "state": "done",
+ "picking_id": one_line_picking.id,
+ "location_dest_id": self.packing_location.id,
+ },
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ # will be done when the second line of the picking is unloaded
+ "state": "assigned",
+ "picking_id": two_lines_picking.id,
+ "location_dest_id": self.packing_location.id,
+ },
+ {
+ "shopfloor_unloaded": False,
+ "qty_done": 0,
+ "state": "assigned",
+ "picking_id": two_lines_picking.id,
+ "location_dest_id": self.packing_location.id,
+ },
+ ],
+ )
+ self.assertRecordValues(self.batch, [{"state": "in_progress"}])
+
+ self.assert_response(
+ # the remaining move line still needs to be picked
+ response,
+ next_state="start_line",
+ data=self._line_data(move_lines[2]),
+ )
+
+ def test_set_destination_all_but_different_dest(self):
+ """Endpoint was called but destinations are different"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines[:2].write({"location_dest_id": self.packing_a_location.id})
+ move_lines[2:].write({"location_dest_id": self.packing_b_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
+ self.assert_response(
+ response,
+ next_state="unload_single",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
+ "location_dst": {
+ "id": move_lines[0].location_dest_id.id,
+ "name": move_lines[0].location_dest_id.name,
+ },
+ },
+ )
+
+ def test_set_destination_all_error_location_not_found(self):
+ """Endpoint called with a barcode not existing for a location"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines.write({"location_dest_id": self.packing_a_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={"picking_batch_id": self.batch.id, "barcode": "NOTFOUND"},
+ )
+ self.assert_response(
+ response,
+ next_state="unload_all",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "location_dst": {
+ "id": move_lines[0].location_dest_id.id,
+ "name": move_lines[0].location_dest_id.name,
+ },
+ },
+ message={
+ "message_type": "error",
+ "message": "No location found for this barcode.",
+ },
+ )
+
+ def test_set_destination_all_error_location_invalid(self):
+ """Endpoint called with a barcode for an invalid location
+
+ It is invalid when the location is not the destination location or
+ sublocation of the picking type.
+ """
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines.write({"location_dest_id": self.packing_a_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.dispatch_location.barcode,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="unload_all",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "location_dst": {
+ "id": move_lines[0].location_dest_id.id,
+ "name": move_lines[0].location_dest_id.name,
+ },
+ },
+ message={"message_type": "error", "message": "You cannot place it here"},
+ )
+
+ def test_set_destination_all_need_confirmation(self):
+ """Endpoint called with a barcode for another (valid) location"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines.write({"location_dest_id": self.packing_a_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_b_location.barcode,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="confirm_unload_all",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "location_dst": {
+ "id": move_lines[0].location_dest_id.id,
+ "name": move_lines[0].location_dest_id.name,
+ },
+ },
+ )
+
+ def test_set_destination_all_with_confirmation(self):
+ """Endpoint called with a barcode for another (valid) location, confirm"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines.write({"location_dest_id": self.packing_a_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_b_location.barcode,
+ "confirmation": True,
+ },
+ )
+ self.assertRecordValues(
+ move_lines,
+ [
+ {"location_dest_id": self.packing_b_location.id},
+ {"location_dest_id": self.packing_b_location.id},
+ {"location_dest_id": self.packing_b_location.id},
+ ],
+ )
+ self.assert_response(
+ response,
+ next_state="start",
+ message={"message_type": "info", "message": "Batch Transfer complete"},
+ )
+
+
+class ClusterPickingUnloadSplitCase(ClusterPickingUnloadingCommonCase):
+ """Tests covering the /unload_split endpoint
+
+ All the destinations of the bins were the same so the "unload all" screen
+ was presented to the user, but they want different destination, so they hit
+ the "split" button. From now on, the workflow should use the "unload single"
+ screen even if the destinations are the same.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # this is what the /prepare_endpoint method would have set as all the
+ # destinations are the same:
+ cls.batch.cluster_picking_unload_all = True
+
+ def test_unload_split_ok(self):
+ """Call /unload_split and continue to unload single"""
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ # put destination packages, the whole quantity on lines and a similar
+ # destination (when /set_destination_all is called, all the lines to
+ # unload must have the same destination)
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines.write({"location_dest_id": self.packing_location.id})
+
+ response = self.service.dispatch(
+ "unload_split", params={"picking_batch_id": self.batch.id}
+ )
+ self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
+ self.assert_response(
+ # the remaining move line still needs to be picked
+ response,
+ next_state="unload_single",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
+ "location_dst": {
+ "id": move_lines[0].location_dest_id.id,
+ "name": move_lines[0].location_dest_id.name,
+ },
+ },
+ )
+
+
+class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase):
+ """Tests covering the /unload_scan_pack endpoint
+
+ Goods have been put in the package bins, they have different destinations
+ or /unload_split has been called, now user has to unload package per
+ package. For this, they'll first scan the bin package, which will call the
+ endpoint /unload_scan_pack. (second step will be to set the destination
+ with /unload_scan_destination, in a different test case)
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.batch.cluster_picking_unload_all = False
+ cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids")
+ cls._set_dest_package_and_done(cls.move_lines, cls.bin1)
+ cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id})
+ cls.move_lines[2:].write({"location_dest_id": cls.packing_b_location.id})
+
+ def test_unload_scan_pack_ok(self):
+ """Endpoint /unload_scan_pack is called, result ok"""
+ response = self.service.dispatch(
+ "unload_scan_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin1.id,
+ "barcode": self.bin1.name,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="unload_set_destination",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
+ "location_dst": {
+ "id": self.move_lines[0].location_dest_id.id,
+ "name": self.move_lines[0].location_dest_id.name,
+ },
+ },
+ )
+
+ def test_unload_scan_pack_wrong_barcode(self):
+ """Endpoint /unload_scan_pack is called, wrong barcode scanned"""
+ response = self.service.dispatch(
+ "unload_scan_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin1.id,
+ "barcode": self.bin2.name,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="unload_single",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
+ "location_dst": {
+ "id": self.move_lines[0].location_dest_id.id,
+ "name": self.move_lines[0].location_dest_id.name,
+ },
+ },
+ message={"message_type": "error", "message": "Wrong bin"},
+ )
From e771900793eb0736a59a0f16c2f962b02e4c8ce4 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 27 Feb 2020 10:54:42 +0100
Subject: [PATCH 116/940] cluster_picking: adjust information for batch
---
shopfloor/services/cluster_picking.py | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index f3400b21ea..3675796e94 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -142,10 +142,16 @@ def _response_for_no_batch_found(self):
def _response_for_confirm_start(self, batch):
pickings = []
for picking in batch.picking_ids:
+ # TODO: is this correct?
+ p_weight = sum(
+ picking.mapped('move_line_ids.product_id.weight')
+ )
p_values = {
"id": picking.id,
"name": picking.name,
+ "partner": None,
"move_line_count": len(picking.move_line_ids),
+ "weight": p_weight,
"origin": picking.origin or "",
}
if picking.partner_id:
@@ -159,8 +165,6 @@ def _response_for_confirm_start(self, batch):
data={
"id": batch.id,
"name": batch.name,
- # TODO
- "weight": 0,
"pickings": pickings,
},
)
@@ -1140,7 +1144,6 @@ def _schema_for_batch_details(self):
# id is a stock.picking.batch
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
- "weight": {"type": "float", "nullable": False, "required": True},
"pickings": {
"type": "list",
"required": True,
@@ -1150,6 +1153,7 @@ def _schema_for_batch_details(self):
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"move_line_count": {"required": True, "type": "integer"},
+ "weight": {"type": "float", "nullable": False, "required": True},
"origin": {
"type": "string",
"nullable": False,
@@ -1158,6 +1162,7 @@ def _schema_for_batch_details(self):
"partner": {
"type": "dict",
"required": False,
+ "nullable": True,
"schema": {
"id": {"required": True, "type": "integer"},
"name": {
From 7baa0d09feb1e223c1e65bc987c212b9dce49342 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 27 Feb 2020 11:15:15 +0100
Subject: [PATCH 117/940] backend: show barcode on location view
---
shopfloor/__manifest__.py | 1 +
shopfloor/views/stock_location.xml | 15 +++++++++++++++
2 files changed, 16 insertions(+)
create mode 100644 shopfloor/views/stock_location.xml
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index b48d72b819..ba49f92715 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -25,6 +25,7 @@
"views/shopfloor_menu.xml",
"views/shopfloor_process.xml",
"views/stock_picking_type.xml",
+ "views/stock_location.xml",
"views/shopfloor_profile_views.xml",
"views/menus.xml",
],
diff --git a/shopfloor/views/stock_location.xml b/shopfloor/views/stock_location.xml
new file mode 100644
index 0000000000..58d2869cb4
--- /dev/null
+++ b/shopfloor/views/stock_location.xml
@@ -0,0 +1,15 @@
+
+
+
+ Shopfloor stock.location form
+ stock.location
+
+
+
+
+
+
+
+
+
+
From 333429eb374716c85b693bbea12f3dec4b88749d Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Feb 2020 08:58:26 +0100
Subject: [PATCH 118/940] backend: allow `success` message type
---
shopfloor/services/validator.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
index 67b2b16b38..f4e1e3373c 100644
--- a/shopfloor/services/validator.py
+++ b/shopfloor/services/validator.py
@@ -82,7 +82,7 @@ def _response_schema(self, data_schema=None, next_states=None):
"message_type": {
"type": "string",
"required": True,
- "allowed": ["info", "warning", "error"],
+ "allowed": ["info", "warning", "error", "success"],
},
"message": {"type": "string", "required": True},
},
From db7c9610e0eb09fd03fdde0ddb5bf684c049b989 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Feb 2020 09:01:53 +0100
Subject: [PATCH 119/940] backend: successful ended operation -> `success` msg
type
---
shopfloor/actions/message.py | 2 +-
shopfloor/services/cluster_picking.py | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 448e54c315..49612a4ac0 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -94,7 +94,7 @@ def need_confirmation(self):
def confirm_pack_moved(self):
return {
- "message_type": "info",
+ "message_type": "success",
"message": _("The pack has been moved, you can scan a new pack."),
}
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 3675796e94..13894d40f5 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -485,7 +485,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
next_state="start_line",
data=self._data_move_line(next_line),
message={
- "message_type": "info",
+ "message_type": "success",
# TODO different message for products/packs?
"message": _("{} {} put in {}").format(
move_line.qty_done,
@@ -780,7 +780,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
def _response_batch_complete(self):
return self._response(
next_state="start",
- message={"message_type": "info", "message": _("Batch Transfer complete")},
+ message={"message_type": "success", "message": _("Batch Transfer complete")},
)
def unload_split(self, picking_batch_id):
From 927ec7ab68f6d6b32446b183f24a0d17a042f143 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Feb 2020 09:07:22 +0100
Subject: [PATCH 120/940] backend: cluster_picking sort lines by location
---
shopfloor/services/cluster_picking.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 13894d40f5..f3340991e3 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -238,7 +238,13 @@ def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func)
# TODO we probably don't care about the postponed order in the 'set
# destination location' step, reset it on 'scan_destination_pack'?
- return lines.sorted(key=lambda x: x.shopfloor_postponed)
+ # TODO test line sorting and all these methods to retrieve lines
+
+ # Sort line by source location,
+ # so that the picker start w/ products in the same location.
+ # Postponed lines must come always
+ # after ALL the other lines in the batch are processed.
+ return lines.sorted(key=lambda x: (x.location_id, x.shopfloor_postponed))
def _lines_to_pick(self, picking_batch):
return self._lines_for_picking_batch(
From 8f227d9eda060e53ee77566b36a7618c3a8da3ad Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Feb 2020 09:11:40 +0100
Subject: [PATCH 121/940] backend: cluster_picking/scan_line suggest
destination bin
---
shopfloor/services/cluster_picking.py | 29 ++++++++++++++++++++++++---
1 file changed, 26 insertions(+), 3 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index f3340991e3..3d18bb2c08 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -259,6 +259,14 @@ def _lines_to_pick(self, picking_batch):
),
)
+ def _last_picked_line(self, picking):
+ """Get the last line packed for this picking."""
+ return fields.first(
+ picking.move_line_ids.filtered(
+ lambda l: l.qty_done > 0 and l.result_package_id
+ )
+ )
+
def _next_line_for_pick(self, picking_batch):
remaining_lines = self._lines_to_pick(picking_batch)
return fields.first(remaining_lines)
@@ -430,9 +438,15 @@ def _response_for_scan_line_product_need_lot(self, move_line):
)
def _response_for_scan_line_ok(self, move_line):
- return self._response(
- next_state="scan_destination", data=self._data_move_line(move_line)
- )
+ data = self._data_move_line(move_line)
+ last_picked_line = self._last_picked_line(move_line.picking_id)
+ if last_picked_line:
+ # suggest pack to be used for the next line
+ data["destination_pack"] = {
+ "id": last_picked_line.result_package_id.id,
+ "name": last_picked_line.result_package_id.name,
+ }
+ return self._response(next_state="scan_destination", data=data)
def scan_destination_pack(self, move_line_id, barcode, quantity):
"""Scan the destination package (bin) for a move line
@@ -1263,6 +1277,15 @@ def _schema_for_single_line_details(self):
"name": {"type": "string", "nullable": False, "required": True},
},
},
+ "destination_pack": {
+ "type": "dict",
+ "required": False,
+ "nullable": True,
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
}
@property
From c71f948335c124354b38aa2790f1df913bcab9f2 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Feb 2020 09:13:06 +0100
Subject: [PATCH 122/940] backend: cluster_picking include `partner` in move
line data
---
shopfloor/services/cluster_picking.py | 25 +++++++++++++++++++++++--
1 file changed, 23 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 3d18bb2c08..bb2e60a4f5 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -285,7 +285,7 @@ def _data_move_line(self, line):
product = line.product_id
lot = line.lot_id
package = line.package_id
- return {
+ data = {
# TODO have common methods to return general info
# for each model
"id": line.id,
@@ -296,6 +296,7 @@ def _data_move_line(self, line):
"name": picking.name,
"origin": picking.origin or "",
"note": picking.note or "",
+ "partner": None,
},
"batch": {"id": batch.id, "name": batch.name},
"product": {
@@ -315,6 +316,14 @@ def _data_move_line(self, line):
},
"pack": {"id": package.id, "name": package.name} if package else None,
}
+ if picking.partner_id:
+ # TODO retrieve info always in the same way
+ # maybe using base_jsonify
+ data["picking"]["partner"] = {
+ "id": picking.partner_id.id,
+ "name": picking.partner_id.name,
+ }
+ return data
def unassign(self, picking_batch_id):
"""Unassign and reset to draft a started picking batch
@@ -1211,6 +1220,19 @@ def _schema_for_single_line_details(self):
"name": {"type": "string", "nullable": False, "required": True},
"origin": {"type": "string", "nullable": False, "required": True},
"note": {"type": "string", "nullable": False, "required": True},
+ "partner": {
+ "type": "dict",
+ "required": False,
+ "nullable": True,
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
},
},
"batch": {
@@ -1267,7 +1289,6 @@ def _schema_for_single_line_details(self):
"name": {"type": "string", "nullable": False, "required": True},
},
},
- # TODO add destination pack
"pack": {
"type": "dict",
"required": False,
From 2692cbea7a46021cec7b46e73045f5f30680e015 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Feb 2020 09:13:50 +0100
Subject: [PATCH 123/940] backend: just linting
---
shopfloor/services/cluster_picking.py | 16 +++++++---------
1 file changed, 7 insertions(+), 9 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index bb2e60a4f5..57e8b73f98 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -143,9 +143,7 @@ def _response_for_confirm_start(self, batch):
pickings = []
for picking in batch.picking_ids:
# TODO: is this correct?
- p_weight = sum(
- picking.mapped('move_line_ids.product_id.weight')
- )
+ p_weight = sum(picking.mapped("move_line_ids.product_id.weight"))
p_values = {
"id": picking.id,
"name": picking.name,
@@ -162,11 +160,7 @@ def _response_for_confirm_start(self, batch):
pickings.append(p_values)
return self._response(
next_state="confirm_start",
- data={
- "id": batch.id,
- "name": batch.name,
- "pickings": pickings,
- },
+ data={"id": batch.id, "name": batch.name, "pickings": pickings},
)
def _response_for_batch_cannot_be_selected(self):
@@ -1182,7 +1176,11 @@ def _schema_for_batch_details(self):
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"move_line_count": {"required": True, "type": "integer"},
- "weight": {"type": "float", "nullable": False, "required": True},
+ "weight": {
+ "type": "float",
+ "nullable": False,
+ "required": True,
+ },
"origin": {
"type": "string",
"nullable": False,
From 1c803fed012c438c64fd4894a04f43f1268299c3 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Feb 2020 09:48:33 +0100
Subject: [PATCH 124/940] backend: cluster_picking improve not implemented msg
---
shopfloor/services/cluster_picking.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 57e8b73f98..40dea0b9da 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -220,7 +220,7 @@ def confirm_start(self, picking_batch_id):
next_state="start",
message={
"message_type": "error",
- "message": "no lines remaining, not implemented",
+ "message": "No lines remaining, should go to 'Prepare unload' but is not implemented yet",
},
)
From 7b05ad3d7fd6c81f19a38277d320787f68fc70ad Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Feb 2020 09:49:42 +0100
Subject: [PATCH 125/940] backend: cluster_picking/scan_destination_pack ->
prepare_unload when ready
---
shopfloor/services/cluster_picking.py | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 40dea0b9da..a1a61d6593 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -496,14 +496,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
batch = move_line.picking_id.batch_id
next_line = self._next_line_for_pick(batch)
if not next_line:
- # TODO
- return self._response(
- next_state="start",
- message={
- "message_type": "error",
- "message": "no lines remaining, not implemented",
- },
- )
+ return self.prepare_unload(batch.id)
return self._response(
next_state="start_line",
data=self._data_move_line(next_line),
From 459ffdd7d14db1771021483d773f0b224e55bc7c Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Feb 2020 14:47:48 +0100
Subject: [PATCH 126/940] backend: just linting
---
shopfloor/services/cluster_picking.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index a1a61d6593..b5b33ff2c2 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -796,7 +796,10 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
def _response_batch_complete(self):
return self._response(
next_state="start",
- message={"message_type": "success", "message": _("Batch Transfer complete")},
+ message={
+ "message_type": "success",
+ "message": _("Batch Transfer complete"),
+ },
)
def unload_split(self, picking_batch_id):
From d96bbc677e4a308a7f00946c56d96be58baf18ca Mon Sep 17 00:00:00 2001
From: Thierry Ducrest
Date: Tue, 25 Feb 2020 11:54:49 +0100
Subject: [PATCH 127/940] Fix confirmation location changed on PutAway
---
shopfloor/actions/message.py | 7 +++++++
shopfloor/services/single_pack_putaway.py | 14 ++++++++++----
shopfloor/tests/test_single_pack_putaway.py | 17 ++++++++++++++++-
3 files changed, 33 insertions(+), 5 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 49612a4ac0..5bee87dbef 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -92,6 +92,13 @@ def dest_location_not_allowed(self):
def need_confirmation(self):
return {"message_type": "warning", "message": _("Are you sure?")}
+ def confirm_location_changed(self, from_location, to_location):
+ return {
+ "message_type": "warning",
+ "message": _("Confirm location change from %s to %s?")
+ % (from_location.name, to_location.name),
+ }
+
def confirm_pack_moved(self):
return {
"message_type": "success",
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index a4239c0880..70cdfdc92a 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -191,10 +191,14 @@ def _response_for_forbidden_location(self, move_line, pack):
data=self._data_after_package_scanned(move_line, pack),
)
- def _response_for_location_need_confirm(self):
+ def _response_for_location_need_confirm(self, move_line, pack, to_location):
message = self.actions_for("message")
return self._response(
- next_state="confirm_location", message=message.need_confirmation()
+ next_state="confirm_location",
+ message=message.confirm_location_changed(
+ move_line.location_dest_id, to_location
+ ),
+ data=self._data_after_package_scanned(move_line, pack),
)
def _response_for_validate_success(self):
@@ -228,7 +232,9 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if not scanned_location.is_sublocation_of(move.location_dest_id):
move.location_dest_id = scanned_location.id
else:
- return self._response_for_location_need_confirm()
+ return self._response_for_location_need_confirm(
+ move_line, package.package_id, scanned_location
+ )
pack_transfer.set_destination_and_done(move, scanned_location)
return self._response_for_validate_success()
@@ -296,7 +302,7 @@ def _states(self):
"start": {},
"confirm_start": self._schema_for_location,
"scan_location": self._schema_for_location,
- "confirm_location": {},
+ "confirm_location": self._schema_for_location,
}
def cancel(self):
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 1ff1fefe84..c447aebace 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -403,6 +403,7 @@ def test_validate_location_to_confirm(self):
# was already started by the first step (start operation)
package_level = self._simulate_started()
+ move = package_level.move_line_ids.move_id
# expected destination is 'shelf1', we'll scan shelf2 which must
# ask a confirmation to the user (it's still in the same picking type)
response = self.service.dispatch(
@@ -412,11 +413,25 @@ def test_validate_location_to_confirm(self):
"location_barcode": self.shelf2.barcode,
},
)
+ message = self.service.actions_for("message").confirm_location_changed(
+ self.shelf1, self.shelf2
+ )
self.assert_response(
response,
next_state="confirm_location",
- message={"message_type": "warning", "message": "Are you sure?"},
+ message=message,
+ data={
+ "id": self.ANY,
+ "location_src": {
+ "id": self.dispatch_location.id,
+ "name": self.dispatch_location.name,
+ },
+ "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name},
+ "name": package_level.package_id.name,
+ "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ "product": {"id": move.product_id.id, "name": move.product_id.name},
+ },
)
def test_validate_location_with_confirm(self):
From 16085635965baf9eb9379bb96b0373d403343816 Mon Sep 17 00:00:00 2001
From: Thierry Ducrest
Date: Wed, 26 Feb 2020 09:38:47 +0100
Subject: [PATCH 128/940] Fix confirm location changed on Pallet Transer
---
shopfloor/services/single_pack_transfer.py | 14 ++++++++++----
shopfloor/tests/test_single_pack_transfer.py | 13 ++++++++++++-
2 files changed, 22 insertions(+), 5 deletions(-)
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 550ee27ec8..30120c4fd2 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -151,10 +151,14 @@ def _response_for_forbidden_location(self, move_line, pack):
data=self._data_after_package_scanned(move_line, pack),
)
- def _response_for_location_need_confirm(self):
+ def _response_for_location_need_confirm(self, move_line, pack, to_location):
message = self.actions_for("message")
return self._response(
- next_state="confirm_location", message=message.need_confirmation()
+ next_state="confirm_location",
+ message=message.confirm_location_changed(
+ move_line.location_dest_id, to_location
+ ),
+ data=self._data_after_package_scanned(move_line, pack),
)
def _response_for_validate_success(self, last=False):
@@ -194,7 +198,9 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if not scanned_location.is_sublocation_of(move.location_dest_id):
move.location_dest_id = scanned_location.id
else:
- return self._response_for_location_need_confirm()
+ return self._response_for_location_need_confirm(
+ move_line, package.package_id, scanned_location
+ )
pack_transfer.set_destination_and_done(move, scanned_location)
last = move.picking_id.completion_info == "next_picking_ready"
@@ -263,7 +269,7 @@ def _states(self):
"start": {},
"confirm_start": self._schema_for_location,
"scan_location": self._schema_for_location,
- "confirm_location": {},
+ "confirm_location": self._schema_for_location,
"show_completion_info": {},
}
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index e2d57047d3..c6bada016b 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -542,10 +542,21 @@ def test_validate_location_to_confirm(self):
},
)
+ message = self.service.actions_for("message").confirm_location_changed(
+ self.shelf2, self.shelf1
+ )
self.assert_response(
response,
next_state="confirm_location",
- message={"message_type": "warning", "message": "Are you sure?"},
+ message=message,
+ data={
+ "id": self.ANY,
+ "name": package_level.package_id.name,
+ "location_src": {"id": self.shelf1.id, "name": self.shelf1.name},
+ "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name},
+ "picking": {"id": self.picking.id, "name": self.picking.name},
+ "product": {"id": self.product_a.id, "name": self.product_a.name},
+ },
)
def test_validate_location_with_confirm(self):
From 905f6479fdf2c750d84844f5408b08bbc23f80f5 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Feb 2020 15:06:17 +0100
Subject: [PATCH 129/940] backend: scan location msg provided by frontend
---
shopfloor/services/cluster_picking.py | 3 ++-
shopfloor/services/single_pack_transfer.py | 2 --
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index b5b33ff2c2..b1e34382ee 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -220,7 +220,8 @@ def confirm_start(self, picking_batch_id):
next_state="start",
message={
"message_type": "error",
- "message": "No lines remaining, should go to 'Prepare unload' but is not implemented yet",
+ "message": "No lines remaining. "
+ "Should go to 'Prepare unload' but is not implemented yet",
},
)
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 30120c4fd2..01f5c8b5f0 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -74,10 +74,8 @@ def _response_for_start_to_confirm(self, move_line, pack):
)
def _response_for_start_success(self, move_line, pack):
- message = self.actions_for("message")
return self._response(
next_state="scan_location",
- message=message.scan_destination(),
data=self._data_after_package_scanned(move_line, pack),
)
From 43aa99838e3bc8b95acf5f385429f51e03855151 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Feb 2020 15:08:39 +0100
Subject: [PATCH 130/940] backend: fix xmlid
---
shopfloor/views/stock_location.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/views/stock_location.xml b/shopfloor/views/stock_location.xml
index 58d2869cb4..3bb76f9884 100644
--- a/shopfloor/views/stock_location.xml
+++ b/shopfloor/views/stock_location.xml
@@ -1,6 +1,6 @@
-
+
Shopfloor stock.location form
stock.location
From 4c9baef34753d72116a052f20c57392946252807 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 2 Mar 2020 13:46:55 +0100
Subject: [PATCH 131/940] backend: fix tests
---
shopfloor/services/cluster_picking.py | 2 +-
shopfloor/tests/test_cluster_picking_base.py | 1 +
shopfloor/tests/test_cluster_picking_scan.py | 2 +-
shopfloor/tests/test_cluster_picking_select.py | 16 +++++++---------
shopfloor/tests/test_cluster_picking_unload.py | 4 ++--
shopfloor/tests/test_single_pack_putaway.py | 4 ++--
shopfloor/tests/test_single_pack_transfer.py | 11 +++--------
7 files changed, 17 insertions(+), 23 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index b1e34382ee..20e076ac8c 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -142,7 +142,7 @@ def _response_for_no_batch_found(self):
def _response_for_confirm_start(self, batch):
pickings = []
for picking in batch.picking_ids:
- # TODO: is this correct?
+ # FIXME weight must be multiplied by quantity
p_weight = sum(picking.mapped("move_line_ids.product_id.weight"))
p_values = {
"id": picking.id,
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index 02574d89b4..0756b05fef 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -75,6 +75,7 @@ def _line_data(self, move_line, qty=None):
"name": picking.name,
"note": "",
"origin": picking.origin,
+ "partner": {"id": self.customer.id, "name": self.customer.name},
},
"batch": {"id": batch.id, "name": batch.name},
"product": {
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 38f0b7a40b..1b258273cc 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -225,7 +225,7 @@ def test_scan_destination_pack_ok(self):
next_state="start_line",
data=self._line_data(next_line),
message={
- "message_type": "info",
+ "message_type": "success",
"message": "{} {} put in {}".format(
line.qty_done, line.product_id.display_name, self.bin1.name
),
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index f8e8b0d5dc..91cf8379b5 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -59,14 +59,14 @@ def test_find_batch_in_progress_current_user(self):
data={
"id": self.batch3.id,
"name": self.batch3.name,
- # TODO
- "weight": 0,
"pickings": [
{
"id": self.batch3.picking_ids.id,
"name": self.batch3.picking_ids.name,
"move_line_count": len(self.batch3.picking_ids.move_line_ids),
"origin": self.batch3.picking_ids.origin,
+ # TODO check weight
+ "weight": 0,
"partner": {
"id": self.batch3.picking_ids.partner_id.id,
"name": self.batch3.picking_ids.partner_id.name,
@@ -97,14 +97,14 @@ def test_find_batch_assigned(self):
data={
"id": self.batch2.id,
"name": self.batch2.name,
- # TODO
- "weight": 0,
"pickings": [
{
"id": self.batch2.picking_ids.id,
"name": self.batch2.picking_ids.name,
"move_line_count": len(self.batch2.picking_ids.move_line_ids),
"origin": self.batch2.picking_ids.origin,
+ # TODO check weight
+ "weight": 0,
"partner": {
"id": self.batch2.picking_ids.partner_id.id,
"name": self.batch2.picking_ids.partner_id.name,
@@ -134,14 +134,14 @@ def test_find_batch_unassigned_draft(self):
data={
"id": self.batch2.id,
"name": self.batch2.name,
- # TODO
- "weight": 0,
"pickings": [
{
"id": self.batch2.picking_ids.id,
"name": self.batch2.picking_ids.name,
"move_line_count": len(self.batch2.picking_ids.move_line_ids),
"origin": self.batch2.picking_ids.origin,
+ # TODO check weight
+ "weight": 0,
"partner": {
"id": self.batch2.picking_ids.partner_id.id,
"name": self.batch2.picking_ids.partner_id.name,
@@ -225,7 +225,6 @@ def test_select_in_progress_assigned(self):
"name": self.batch1.name,
# we don't care in these tests, the 'find_batch' tests already
# check this
- "weight": self.ANY,
"pickings": self.ANY,
},
)
@@ -249,7 +248,6 @@ def test_select_draft_assigned(self):
"name": self.batch1.name,
# we don't care in these tests, the 'find_batch' tests already
# check this
- "weight": self.ANY,
"pickings": self.ANY,
},
)
@@ -272,7 +270,6 @@ def test_select_draft_unassigned(self):
"name": self.batch1.name,
# we don't care in these tests, the 'find_batch' tests already
# check this
- "weight": self.ANY,
"pickings": self.ANY,
},
)
@@ -387,6 +384,7 @@ def test_confirm_start_ok(self):
"name": picking.name,
"note": "",
"origin": picking.origin,
+ "partner": {"id": self.customer.id, "name": self.customer.name},
},
"batch": {"id": batch.id, "name": batch.name},
"product": {
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 5fc13b3137..ed522c66dc 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -169,7 +169,7 @@ def test_set_destination_all_ok(self):
self.assert_response(
response,
next_state="start",
- message={"message_type": "info", "message": "Batch Transfer complete"},
+ message={"message_type": "success", "message": "Batch Transfer complete"},
)
def test_set_destination_all_remaining_lines(self):
@@ -373,7 +373,7 @@ def test_set_destination_all_with_confirmation(self):
self.assert_response(
response,
next_state="start",
- message={"message_type": "info", "message": "Batch Transfer complete"},
+ message={"message_type": "success", "message": "Batch Transfer complete"},
)
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index c447aebace..9bad651030 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -282,7 +282,7 @@ def test_validate(self):
response,
next_state="start",
message={
- "message_type": "info",
+ "message_type": "success",
"message": "The pack has been moved, you can scan a new pack.",
},
)
@@ -473,7 +473,7 @@ def test_validate_location_with_confirm(self):
response,
next_state="start",
message={
- "message_type": "info",
+ "message_type": "success",
"message": "The pack has been moved, you can scan a new pack.",
},
)
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index c6bada016b..c4c0a69cb0 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -97,10 +97,6 @@ def test_start(self):
self.assert_response(
response,
next_state="scan_location",
- message={
- "message_type": "info",
- "message": "Scan the destination location",
- },
data={
"id": self.ANY,
"name": package_level.package_id.name,
@@ -193,7 +189,6 @@ def test_start_pack_from_location(self):
# checked in the test_start test.
response,
next_state="scan_location",
- message=self.ANY,
data=self.ANY,
)
@@ -365,7 +360,7 @@ def test_validate(self):
response,
next_state="start",
message={
- "message_type": "info",
+ "message_type": "success",
"message": "The pack has been moved, you can scan a new pack.",
},
)
@@ -420,7 +415,7 @@ def test_validate_completion_info(self):
response,
next_state="show_completion_info",
message={
- "message_type": "info",
+ "message_type": "success",
"message": "The pack has been moved, you can scan a new pack.",
},
)
@@ -598,7 +593,7 @@ def test_validate_location_with_confirm(self):
response,
next_state="start",
message={
- "message_type": "info",
+ "message_type": "success",
"message": "The pack has been moved, you can scan a new pack.",
},
)
From e08b374ebf8dce13b0c858ee63739a84806ead68 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 2 Mar 2020 14:27:06 +0100
Subject: [PATCH 132/940] cluster_picking: correct weight computation
---
shopfloor/services/cluster_picking.py | 5 +++--
shopfloor/tests/test_cluster_picking_base.py | 2 ++
shopfloor/tests/test_cluster_picking_select.py | 18 ++++++++----------
3 files changed, 13 insertions(+), 12 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 20e076ac8c..05d60d76b6 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -142,8 +142,9 @@ def _response_for_no_batch_found(self):
def _response_for_confirm_start(self, batch):
pickings = []
for picking in batch.picking_ids:
- # FIXME weight must be multiplied by quantity
- p_weight = sum(picking.mapped("move_line_ids.product_id.weight"))
+ p_weight = 0.0
+ for move_line in picking.mapped("move_line_ids"):
+ p_weight += move_line.product_qty * move_line.product_id.weight
p_values = {
"id": picking.id,
"name": picking.name,
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index 0756b05fef..bf97800b41 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -11,6 +11,7 @@ def setUpClass(cls, *args, **kwargs):
"type": "product",
"default_code": "A",
"barcode": "A",
+ "weight": 2,
}
)
cls.product_b = cls.env["product.product"].create(
@@ -19,6 +20,7 @@ def setUpClass(cls, *args, **kwargs):
"type": "product",
"default_code": "B",
"barcode": "B",
+ "weight": 3,
}
)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index 91cf8379b5..63b05e04e7 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -24,16 +24,16 @@ def setUpClass(cls, *args, **kwargs):
# drop base demo data and create our own batches to work with
cls.env["stock.picking.batch"].search([]).unlink()
cls.batch1 = cls._create_picking_batch(
- [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ [[cls.BatchProduct(product=cls.product_a, quantity=3)]]
)
cls.batch2 = cls._create_picking_batch(
- [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ [[cls.BatchProduct(product=cls.product_a, quantity=3)]]
)
cls.batch3 = cls._create_picking_batch(
- [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ [[cls.BatchProduct(product=cls.product_a, quantity=3)]]
)
cls.batch4 = cls._create_picking_batch(
- [[cls.BatchProduct(product=cls.product_a, quantity=1)]]
+ [[cls.BatchProduct(product=cls.product_a, quantity=3)]]
)
def _add_stock_and_assign_pickings_for_batches(self, batches):
@@ -65,8 +65,8 @@ def test_find_batch_in_progress_current_user(self):
"name": self.batch3.picking_ids.name,
"move_line_count": len(self.batch3.picking_ids.move_line_ids),
"origin": self.batch3.picking_ids.origin,
- # TODO check weight
- "weight": 0,
+ # quantity of products (3) * weight of product (2)
+ "weight": 6.0,
"partner": {
"id": self.batch3.picking_ids.partner_id.id,
"name": self.batch3.picking_ids.partner_id.name,
@@ -103,8 +103,7 @@ def test_find_batch_assigned(self):
"name": self.batch2.picking_ids.name,
"move_line_count": len(self.batch2.picking_ids.move_line_ids),
"origin": self.batch2.picking_ids.origin,
- # TODO check weight
- "weight": 0,
+ "weight": 6.0,
"partner": {
"id": self.batch2.picking_ids.partner_id.id,
"name": self.batch2.picking_ids.partner_id.name,
@@ -140,8 +139,7 @@ def test_find_batch_unassigned_draft(self):
"name": self.batch2.picking_ids.name,
"move_line_count": len(self.batch2.picking_ids.move_line_ids),
"origin": self.batch2.picking_ids.origin,
- # TODO check weight
- "weight": 0,
+ "weight": 6.0,
"partner": {
"id": self.batch2.picking_ids.partner_id.id,
"name": self.batch2.picking_ids.partner_id.name,
From 89f4e32fc2574c2c9b346b58904cd13d277d55ec Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 2 Mar 2020 16:07:36 +0100
Subject: [PATCH 133/940] backend: fix openapi error when demo data is not
installed
---
shopfloor/services/service.py | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 2d82ba2f1a..6c0861f9f6 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -120,9 +120,7 @@ def _response(self, base_response=None, data=None, next_state=None, message=None
def _get_openapi_default_parameters(self):
defaults = super()._get_openapi_default_parameters()
- demo_api_key = self.env.ref(
- "shopfloor.api_key_demo", raise_if_not_found=False
- ).key
+ demo_api_key = self.env.ref("shopfloor.api_key_demo", raise_if_not_found=False)
# Try to first the first menu that implements the current service.
# Not all usages have a process, in that case, well set the first
@@ -142,7 +140,7 @@ def _get_openapi_default_parameters(self):
"required": True,
"schema": {"type": "string"},
"style": "simple",
- "value": demo_api_key,
+ "value": demo_api_key.key if demo_api_key else "",
},
{
"name": "SERVICE_CTX_MENU_ID",
From 69a30319aa67856f9f327fdf80dc7f8643771114 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 4 Mar 2020 11:47:10 +0100
Subject: [PATCH 134/940] cluster picking: implement unload_scan_destination
---
shopfloor/actions/message.py | 6 +
shopfloor/services/cluster_picking.py | 152 ++++++---
.../tests/test_cluster_picking_unload.py | 317 ++++++++++++++++++
3 files changed, 427 insertions(+), 48 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 5bee87dbef..effb2b5892 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -149,3 +149,9 @@ def no_pending_operation_for_pack(self, pack):
"message_type": "error",
"message": _("No pending operation for package %s." % pack.name),
}
+
+ def unrecoverable_error(self):
+ return {
+ "message_type": "error",
+ "message": _("Unrecoverable error, please restart."),
+ }
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 05d60d76b6..6f3ba361d5 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -216,15 +216,7 @@ def confirm_start(self, picking_batch_id):
next_line = self._next_line_for_pick(picking_batch)
if not next_line:
- # TODO
- return self._response(
- next_state="start",
- message={
- "message_type": "error",
- "message": "No lines remaining. "
- "Should go to 'Prepare unload' but is not implemented yet",
- },
- )
+ return self.prepare_unload(picking_batch)
return self._response(
next_state="start_line", data=self._data_move_line(next_line)
@@ -586,7 +578,10 @@ def _response_for_unload_all_need_confirm(self, batch, message=None):
def _filter_for_unload(self, line):
return (
- line.qty_done > 0 and line.result_package_id and not line.shopfloor_unloaded
+ line.state == "assigned"
+ and line.qty_done > 0
+ and line.result_package_id
+ and not line.shopfloor_unloaded
)
def _lines_to_unload(self, batch):
@@ -604,8 +599,7 @@ def _next_bin_package_for_unload_single(self, batch):
def _response_for_unload_single(self, batch):
next_package = self._next_bin_package_for_unload_single(batch)
if not next_package:
- # TODO ensure batch is 'done' and go to start?
- return self._response()
+ return self._unload_end(batch)
return self._response(
next_state="unload_single",
data=self._data_for_unload_single(batch, next_package),
@@ -641,23 +635,19 @@ def skip_line(self, move_line_id):
"""
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- # TODO go to next line? (but then handle if it's the last one)
- return self._response(next_state="start")
+ message = self.actions_for("message")
+ return self._response(
+ next_state="start", message=message.unrecoverable_error()
+ )
# flag as postponed
move_line.shopfloor_postponed = True
return self._response_for_skip_line(move_line)
def _response_for_skip_line(self, move_line):
- next_line = self._next_line_for_pick(move_line.picking_id.batch_id)
+ batch = move_line.picking_id.batch_id
+ next_line = self._next_line_for_pick(batch)
if not next_line:
- # TODO ensure batch is 'done' and go to start?
- return self._response(
- next_state="start",
- message={
- "message_type": "error",
- "message": "no lines remaining, not implemented",
- },
- )
+ return self.prepare_unload(batch.id)
return self._response(
next_state="start_line", data=self._data_move_line(next_line)
)
@@ -684,9 +674,7 @@ def stock_issue(self, move_line_id):
destination
* start: all lines are done/confirmed (because all lines were unloaded
and the last line has a stock issue). In this case, this method *has*
- to handle the closing of the batch to create backorders. TODO find a
- generic way to share actions happening on transitions such as "close
- the batch"
+ to handle the closing of the batch to create backorders (_unload_end)
"""
return self._response()
@@ -725,9 +713,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
* confirm_unload_all: the scanned location is not the expected one (but
still a valid one)
* start: batch is totally done. In this case, this method *has*
- to handle the closing of the batch to create backorders. TODO find a
- generic way to share actions happening on transitions such as "close
- the batch"
+ to handle the closing of the batch to create backorders.
"""
batch = self.env["stock.picking.batch"].browse(picking_batch_id)
if not batch.exists():
@@ -742,8 +728,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
lines = self._lines_to_unload(batch)
if not lines:
- # TODO a bit unexpected here but deal with it
- return self._response()
+ return self._unload_end(batch)
first_line = fields.first(lines)
picking_type = fields.first(batch.picking_ids).picking_type_id
@@ -763,9 +748,11 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
if not confirmation:
return self._response_for_unload_all_need_confirm(batch)
- lines.write(
- {"shopfloor_unloaded": True, "location_dest_id": scanned_location.id}
- )
+ self._unload_set_destination_on_lines(lines, scanned_location)
+ return self._unload_end(batch)
+
+ def _unload_set_destination_on_lines(self, lines, location):
+ lines.write({"shopfloor_unloaded": True, "location_dest_id": location.id})
for line in lines:
# We set the picking to done only when the last line is
# unloaded to avoid backorders.
@@ -776,6 +763,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
if all(l.shopfloor_unloaded for l in picking_lines):
picking.action_done()
+ def _unload_end(self, batch):
if all(picking.state == "done" for picking in batch.picking_ids):
# do not use the 'done()' method because it does many things we
# don't care about
@@ -826,6 +814,8 @@ def unload_split(self, picking_batch_id):
return self._response_for_unload_single(batch)
+ # TODO we shouldn't need this endpoint if we implement the "completion
+ # info" screen as a kind of generic info box instead of a state
def unload_router(self, picking_batch_id):
"""Called after the info screen, route to the next state
@@ -835,9 +825,7 @@ def unload_router(self, picking_batch_id):
* unload_single: if the batch still has packs to unload
* start_line: if the batch still has lines to pick
* start: if the batch is done. In this case, this method *has*
- to handle the closing of the batch to create backorders. TODO find a
- generic way to share actions happening on transitions such as "close
- the batch"
+ to handle the closing of the batch to create backorders.
"""
return self._response()
@@ -856,17 +844,26 @@ def unload_scan_pack(self, picking_batch_id, package_id, barcode):
return self._response_batch_does_not_exist()
package = self.env["stock.quant.package"].browse(package_id)
if not package.exists():
- # TODO: next package? if no next package, close and go to start?
- return self._response()
+ return self._unload_next_package(batch)
if package.name != barcode:
return self._response(
next_state="unload_single",
data=self._data_for_unload_single(batch, package),
message={"message_type": "error", "message": _("Wrong bin")},
)
+ return self._response_for_unload_set_destination(batch, package)
+
+ def _response_for_unload_set_destination(self, batch, package, message=None):
return self._response(
next_state="unload_set_destination",
data=self._data_for_unload_single(batch, package),
+ message=message,
+ )
+
+ def _response_for_confirm_unload_set_destination(self, batch, package):
+ return self._response(
+ next_state="confirm_unload_set_destination",
+ data=self._data_for_unload_single(batch, package),
)
def unload_scan_destination(
@@ -877,10 +874,6 @@ def unload_scan_destination(
It updates all the assigned move lines with the package to the
destination.
- TODO not sure: We have to call action_done on the picking *only when we
- have scanned all the packages* of the picking, so maybe we have to
- keep track of this with a new flag on move lines?
-
Transitions:
* unload_single: invalid scanned location or error
* unload_single: line is processed and the next bin can be unloaded
@@ -892,12 +885,56 @@ def unload_scan_destination(
client should then call /unload_router to know the next state
* start_line: if the batch still has lines to pick
* start: if the batch is done. In this case, this method *has*
- to handle the closing of the batch to create backorders. TODO find a
- generic way to share actions happening on transitions such as "close
- the batch"
+ to handle the closing of the batch to create backorders.
"""
- return self._response()
+ message = self.actions_for("message")
+
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
+ return self._response_batch_does_not_exist()
+
+ package = self.env["stock.quant.package"].browse(package_id)
+ if not package.exists():
+ return self._unload_next_package(batch)
+
+ # we work only on the lines of the scanned package
+ lines = self._lines_to_unload(batch).filtered(
+ lambda l: l.result_package_id == package
+ )
+ if not lines:
+ return self._unload_end(batch)
+
+ first_line = fields.first(lines)
+ picking_type = fields.first(batch.picking_ids).picking_type_id
+ scanned_location = self.actions_for("search").location_from_scan(barcode)
+ if not scanned_location:
+ return self._response_for_unload_set_destination(
+ batch, package, message=message.no_location_found()
+ )
+ if not scanned_location.is_sublocation_of(
+ picking_type.default_location_dest_id
+ ):
+ return self._response_for_unload_set_destination(
+ batch, package, message=message.dest_location_not_allowed()
+ )
+
+ if not scanned_location.is_sublocation_of(first_line.location_dest_id):
+ if not confirmation:
+ return self._response_for_confirm_unload_set_destination(batch, package)
+
+ self._unload_set_destination_on_lines(lines, scanned_location)
+
+ return self._unload_next_package(batch)
+
+ def _unload_next_package(self, batch):
+ next_package = self._next_bin_package_for_unload_single(batch)
+ if next_package:
+ return self._response(
+ next_state="unload_single",
+ data=self._data_for_unload_single(batch, next_package),
+ )
+ return self._unload_end(batch)
class ShopfloorClusterPickingValidator(Component):
@@ -1044,6 +1081,14 @@ def confirm_start(self):
# unrecoverable error, maybe we should add an attribute
# `_start_state = "start"` and implicitly add it in states
"start",
+ # we reopen a batch already started where all the lines were
+ # already picked and have to be unloaded to the same
+ # destination
+ "unload_all",
+ # we reopen a batch already started where all the lines were
+ # already picked and have to be unloaded to the different
+ # destinations
+ "unload_single",
]
)
@@ -1143,13 +1188,24 @@ def unload_router(self):
def unload_scan_pack(self):
return self._response_schema(
- next_states=["unload_single", "unload_set_destination"]
+ next_states=[
+ # go back to the same state if barcode issue
+ "unload_single",
+ # if the package to scan was deleted, was the last to unload
+ # and we still have lines to pick
+ "start_line",
+ # next "logical" state, when the scan is ok
+ "unload_set_destination",
+ # the package was deleted and was the last one of the batch
+ "start",
+ ]
)
def unload_scan_destination(self):
return self._response_schema(
next_states=[
"unload_single",
+ "unload_set_destination",
"confirm_unload_set_destination",
"show_completion_info",
"start",
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index ed522c66dc..a801367eed 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -489,3 +489,320 @@ def test_unload_scan_pack_wrong_barcode(self):
},
message={"message_type": "error", "message": "Wrong bin"},
)
+
+
+class ClusterPickingUnloadScanDestinationCase(ClusterPickingUnloadingCommonCase):
+ """Tests covering the /unload_scan_destination endpoint
+
+ Goods have been put in the package bins, they have different destinations
+ or /unload_split has been called, now user has to unload package per
+ package. For this, they'll first scanned the bin package already (endpoint
+ /unload_scan_pack), now they have to set the destination with
+ /unload_scan_destination for the scanned pack.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.batch.cluster_picking_unload_all = False
+ cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids")
+ cls.bin1_lines = cls.move_lines[:1]
+ cls.bin2_lines = cls.move_lines[1:]
+ cls._set_dest_package_and_done(cls.bin1_lines, cls.bin1)
+ cls._set_dest_package_and_done(cls.bin2_lines, cls.bin2)
+ cls.bin1_lines.write({"location_dest_id": cls.packing_a_location.id})
+ cls.bin2_lines.write({"location_dest_id": cls.packing_b_location.id})
+ cls.one_line_picking = cls.batch.picking_ids[0]
+ cls.two_lines_picking = cls.batch.picking_ids[1]
+
+ def test_unload_scan_destination_ok(self):
+ """Endpoint /unload_scan_destination is called, result ok"""
+ dest_location = self.bin1_lines[0].location_dest_id
+
+ response = self.service.dispatch(
+ "unload_scan_destination",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin1.id,
+ "barcode": dest_location.barcode,
+ },
+ )
+
+ # The scan of destination set 'unloaded' to True to track the fact
+ # that we set the destination for the line. In this case, the line
+ # and the stock.picking are 'done' because all the lines of the picking
+ # have been unloaded
+ self.assertRecordValues(self.one_line_picking, [{"state": "done"}])
+ self.assertRecordValues(self.two_lines_picking, [{"state": "assigned"}])
+ self.assertRecordValues(
+ self.bin1_lines,
+ [
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ "state": "done",
+ "picking_id": self.one_line_picking.id,
+ "location_dest_id": self.packing_a_location.id,
+ }
+ ],
+ )
+ self.assertRecordValues(
+ self.bin2_lines,
+ [
+ {
+ "shopfloor_unloaded": False,
+ "qty_done": 10,
+ "state": "assigned",
+ "picking_id": self.two_lines_picking.id,
+ "location_dest_id": self.packing_b_location.id,
+ },
+ {
+ "shopfloor_unloaded": False,
+ "qty_done": 10,
+ "state": "assigned",
+ "picking_id": self.two_lines_picking.id,
+ "location_dest_id": self.packing_b_location.id,
+ },
+ ],
+ )
+ self.assertRecordValues(self.batch, [{"state": "in_progress"}])
+
+ self.assert_response(
+ response,
+ next_state="unload_single",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ # the line of bin1 is unloaded, next one will be bin2
+ "package": {"id": self.bin2.id, "name": self.bin2.name},
+ "location_dst": {
+ "id": self.bin2_lines[0].location_dest_id.id,
+ "name": self.bin2_lines[0].location_dest_id.name,
+ },
+ },
+ )
+
+ def test_unload_scan_destination_one_line_of_picking_only(self):
+ """Endpoint /unload_scan_destination is called, only one line of picking"""
+ # For this test, we assume the move in bin1 is already done.
+ self.one_line_picking.action_done()
+ # And for the second picking, we put one line bin2 and one line in bin3
+ # so the user would have to go through 2 screens for each pack.
+ # After scanning and setting the destination for bin2, the picking will
+ # still be "assigned" and they'll have to scan bin3 (but this test stops
+ # at bin2)
+ bin3 = self.env["stock.quant.package"].create({})
+ bin2_line = self.bin2_lines[0]
+ bin3_line = self.bin2_lines[1]
+ self._set_dest_package_and_done(bin3_line, bin3)
+
+ dest_location = bin2_line.location_dest_id
+
+ response = self.service.dispatch(
+ "unload_scan_destination",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin2.id,
+ "barcode": dest_location.barcode,
+ },
+ )
+
+ # The scan of destination set 'unloaded' to True to track the fact
+ # that we set the destination for the line. The picking is still
+ # assigned because the second line still has to be unloaded.
+ self.assertRecordValues(self.two_lines_picking, [{"state": "assigned"}])
+ self.assertRecordValues(
+ bin2_line,
+ [
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ "state": "assigned",
+ "picking_id": self.two_lines_picking.id,
+ "location_dest_id": self.packing_b_location.id,
+ }
+ ],
+ )
+ self.assertRecordValues(
+ bin3_line,
+ [
+ {
+ "shopfloor_unloaded": False,
+ "qty_done": 10,
+ "state": "assigned",
+ "picking_id": self.two_lines_picking.id,
+ "location_dest_id": self.packing_b_location.id,
+ }
+ ],
+ )
+ self.assertRecordValues(self.batch, [{"state": "in_progress"}])
+
+ self.assert_response(
+ response,
+ next_state="unload_single",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ # the line of bin2 is unloaded, next one will be bin3
+ "package": {"id": bin3.id, "name": bin3.name},
+ "location_dst": {
+ "id": bin3_line.location_dest_id.id,
+ "name": bin3_line.location_dest_id.name,
+ },
+ },
+ )
+
+ def test_unload_scan_destination_last_line(self):
+ """Endpoint /unload_scan_destination is called on last line"""
+ # For this test, we assume the move in bin1 is already done.
+ self.one_line_picking.action_done()
+ # And for the second picking, bin2 was already unloaded,
+ # remains bin3 to unload.
+ bin3 = self.env["stock.quant.package"].create({})
+ bin2_line = self.bin2_lines[0]
+ bin3_line = self.bin2_lines[1]
+ self._set_dest_package_and_done(bin3_line, bin3)
+ bin2_line.shopfloor_unloaded = True
+
+ dest_location = bin3_line.location_dest_id
+
+ response = self.service.dispatch(
+ "unload_scan_destination",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": bin3.id,
+ "barcode": dest_location.barcode,
+ },
+ )
+
+ # The scan of destination set 'unloaded' to True to track the fact
+ # that we set the destination for the line. The picking is done
+ # because all the lines have been unloaded
+ self.assertRecordValues(self.two_lines_picking, [{"state": "done"}])
+ self.assertRecordValues(
+ bin3_line,
+ [
+ {
+ "shopfloor_unloaded": True,
+ "qty_done": 10,
+ "state": "done",
+ "picking_id": self.two_lines_picking.id,
+ "location_dest_id": self.packing_b_location.id,
+ }
+ ],
+ )
+ self.assertRecordValues(self.batch, [{"state": "done"}])
+
+ self.assert_response(
+ response,
+ next_state="start",
+ message={"message": "Batch Transfer complete", "message_type": "success"},
+ )
+
+ def test_unload_scan_destination_error_location_not_found(self):
+ """Endpoint called with a barcode not existing for a location"""
+ response = self.service.dispatch(
+ "unload_scan_destination",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin1.id,
+ "barcode": "¤",
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="unload_set_destination",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
+ "location_dst": {
+ "id": self.bin1_lines[0].location_dest_id.id,
+ "name": self.bin1_lines[0].location_dest_id.name,
+ },
+ },
+ message={
+ "message_type": "error",
+ "message": "No location found for this barcode.",
+ },
+ )
+
+ def test_unload_scan_destination_error_location_invalid(self):
+ """Endpoint called with a barcode for an invalid location
+
+ It is invalid when the location is not the destination location or
+ sublocation of the picking type.
+ """
+ response = self.service.dispatch(
+ "unload_scan_destination",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin1.id,
+ "barcode": self.dispatch_location.barcode,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="unload_set_destination",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
+ "location_dst": {
+ "id": self.bin1_lines[0].location_dest_id.id,
+ "name": self.bin1_lines[0].location_dest_id.name,
+ },
+ },
+ message={"message_type": "error", "message": "You cannot place it here"},
+ )
+
+ def test_unload_scan_destination_need_confirmation(self):
+ """Endpoint called with a barcode for another (valid) location"""
+ response = self.service.dispatch(
+ "unload_scan_destination",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin1.id,
+ "barcode": self.packing_b_location.barcode,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="confirm_unload_set_destination",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "package": {"id": self.bin1.id, "name": self.bin1.name},
+ "location_dst": {
+ "id": self.bin1_lines[0].location_dest_id.id,
+ "name": self.bin1_lines[0].location_dest_id.name,
+ },
+ },
+ )
+
+ def test_unload_scan_destination_with_confirmation(self):
+ """Endpoint called with a barcode for another (valid) location, confirm"""
+ response = self.service.dispatch(
+ "unload_scan_destination",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin2.id,
+ "barcode": self.packing_a_location.barcode,
+ "confirmation": True,
+ },
+ )
+ self.assertRecordValues(
+ self.bin2.quant_ids,
+ [
+ {"location_id": self.packing_a_location.id},
+ {"location_id": self.packing_a_location.id},
+ ],
+ )
+ self.assertRecordValues(
+ self.two_lines_picking.move_line_ids,
+ [
+ {"location_dest_id": self.packing_a_location.id},
+ {"location_dest_id": self.packing_a_location.id},
+ ],
+ )
+ self.assert_response(response, next_state="unload_single", data=self.ANY)
From 35a320b25e5255cf93a5e43802c3e7bdb1df991e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 4 Mar 2020 12:51:18 +0100
Subject: [PATCH 135/940] pack putaway: fix test after change of behavior in
odoo
After this commit, the behavior has changed:
https://github.com/odoo/odoo/commit/90ae4d6afe7
When we change the destination location of a package level, it
automatically updates the move to have the same destination location.
Before this commit, the dest. location of the package level was not
updated, now it is updated with the move line's dest, so the move is
updated in turn.
---
shopfloor/tests/test_single_pack_putaway.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 9bad651030..55cbaca4d3 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -76,7 +76,7 @@ def test_start(self):
move_line, [{"qty_done": 1.0, "location_dest_id": self.shelf1.id}]
)
self.assertRecordValues(
- move, [{"state": "assigned", "location_dest_id": self.stock_location.id}]
+ move, [{"state": "assigned", "location_dest_id": self.shelf1.id}]
)
self.assert_response(
response,
From 63613b29b64a6a57b20531e588d9a1c603f1bf61 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 5 Mar 2020 10:11:30 +0100
Subject: [PATCH 136/940] cluster picking: support more edge cases in
scan_destination_pack
---
shopfloor/actions/message.py | 3 +
shopfloor/models/stock_quant_package.py | 4 +-
shopfloor/services/cluster_picking.py | 127 ++++++++-----
shopfloor/tests/test_cluster_picking_base.py | 8 +
shopfloor/tests/test_cluster_picking_scan.py | 175 +++++++++++++++++-
.../tests/test_cluster_picking_unload.py | 8 -
6 files changed, 267 insertions(+), 58 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index effb2b5892..b6bde733ad 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -35,6 +35,9 @@ def package_not_found_for_barcode(self, barcode):
"message": _("The package %s doesn't exist") % barcode,
}
+ def bin_not_found_for_barcode(self, barcode):
+ return {"message_type": "error", "message": _("Bin %s doesn't exist") % barcode}
+
def package_not_allowed_in_src_location(self, barcode, picking_type):
return {
"message_type": "error",
diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py
index 63cbf4ab8c..d41c531f2f 100644
--- a/shopfloor/models/stock_quant_package.py
+++ b/shopfloor/models/stock_quant_package.py
@@ -4,9 +4,9 @@
class StockQuantPackage(models.Model):
_inherit = "stock.quant.package"
- dest_move_line_ids = fields.One2many(
+ planned_move_line_ids = fields.One2many(
comodel_name="stock.move.line",
inverse_name="result_package_id",
readonly=True,
- help="Technical field. Move lines for which destination" " is this package.",
+ help="Technical field. Move lines for which destination is this package.",
)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 6f3ba361d5..69d8914567 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1,4 +1,5 @@
from odoo import _, fields
+from odoo.tools.float_utils import float_compare # , float_is_zero
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
@@ -214,18 +215,20 @@ def confirm_start(self, picking_batch_id):
if not picking_batch.exists():
return self._response_batch_does_not_exist()
- next_line = self._next_line_for_pick(picking_batch)
- if not next_line:
- return self.prepare_unload(picking_batch)
+ return self._pick_next_line(picking_batch)
+ def _pick_next_line(self, batch, message=None):
+ next_line = self._next_line_for_pick(batch)
+ if not next_line:
+ return self.prepare_unload(batch.id)
return self._response(
- next_state="start_line", data=self._data_move_line(next_line)
+ next_state="start_line",
+ data=self._data_move_line(next_line),
+ message=message,
)
def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func)
- # TODO we probably don't care about the postponed order in the 'set
- # destination location' step, reset it on 'scan_destination_pack'?
# TODO test line sorting and all these methods to retrieve lines
# Sort line by source location,
@@ -248,11 +251,11 @@ def _lines_to_pick(self, picking_batch):
)
def _last_picked_line(self, picking):
- """Get the last line packed for this picking."""
+ """Get the last line picked and put in a pack for this picking"""
return fields.first(
picking.move_line_ids.filtered(
lambda l: l.qty_done > 0 and l.result_package_id
- )
+ ).sorted(key="write_date", reverse=True)
)
def _next_line_for_pick(self, picking_batch):
@@ -350,18 +353,18 @@ def scan_line(self, move_line_id, barcode):
message = self.actions_for("message")
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- # TODO go to next line? (but then handle if it's the last one)
- return self._response(next_state="start")
- # TODO check again the state of the move line, if already processed
- # move to the next state or next line
+ return self._response(
+ next_state="start", message=message.unrecoverable_error()
+ )
if move_line.package_id.name == barcode:
- return self._response_for_scan_line_ok(move_line)
+ return self._response_for_scan_destination(move_line)
+ # TODO should search for product packaging too
elif move_line.product_id.barcode == barcode:
if move_line.product_id.tracking in ("lot", "serial"):
return self._response_for_scan_line_product_need_lot(move_line)
- return self._response_for_scan_line_ok(move_line)
+ return self._response_for_scan_destination(move_line)
elif move_line.lot_id.name == barcode:
- return self._response_for_scan_line_ok(move_line)
+ return self._response_for_scan_destination(move_line)
elif move_line.location_id.barcode == barcode:
# When a user scan a location, we accept only when we knows that
# they scanned the good thing, so if in the location we have
@@ -394,7 +397,7 @@ def scan_line(self, move_line_id, barcode):
move_line
)
- return self._response_for_scan_line_ok(move_line)
+ return self._response_for_scan_destination(move_line)
return self._response(
next_state="start_line",
@@ -434,7 +437,7 @@ def _response_for_scan_line_product_need_lot(self, move_line):
message=message.scan_lot_on_product_tracked_by_lot(),
)
- def _response_for_scan_line_ok(self, move_line):
+ def _response_for_scan_destination(self, move_line, message=None):
data = self._data_move_line(move_line)
last_picked_line = self._last_picked_line(move_line.picking_id)
if last_picked_line:
@@ -443,7 +446,7 @@ def _response_for_scan_line_ok(self, move_line):
"id": last_picked_line.result_package_id.id,
"name": last_picked_line.result_package_id.name,
}
- return self._response(next_state="scan_destination", data=data)
+ return self._response(next_state="scan_destination", data=data, message=message)
def scan_destination_pack(self, move_line_id, barcode, quantity):
"""Scan the destination package (bin) for a move line
@@ -465,35 +468,65 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
have the same destination.
* start_line: to pick the next line if any.
"""
+ message = self.actions_for("message")
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- # TODO go to next line? (but then handle if it's the last one)
- return self._response(next_state="start")
- # TODO if another line of the picking has a destination package, handle
- # it (note: should be added to the 'single line' schema /data as well,
- # maybe use a computed field).
-
- # TODO handle partial pick
- if quantity > move_line.product_uom_qty:
- # TODO (+ use float_tools)
- return self._response()
- # TODO handle destination bin not empty
+ return self._response(
+ next_state="start", message=message.unrecoverable_error()
+ )
+ rounding = move_line.product_uom_id.rounding
+ compare = float_compare(
+ quantity, move_line.product_uom_qty, precision_rounding=rounding
+ )
+ qty_lesser = compare == -1
+ qty_greater = compare == 1
+ if qty_greater:
+ return self._response_for_scan_destination(
+ move_line,
+ message={
+ "message_type": "error",
+ "message": _("You must not pick more than {} units.").format(
+ move_line.product_uom_qty
+ ),
+ },
+ )
+ elif qty_lesser:
+ # split the move line which will be processed later (maybe the user
+ # has to pick some goods from another place because the location
+ # contained less items than expected)
+ remaining = move_line.product_uom_qty - quantity
+ # TODO it must be the next line to process
+ move_line.copy({"product_uom_qty": remaining, "qty_done": 0})
+ move_line.product_uom_qty = quantity
+
search = self.actions_for("search")
+ message = self.actions_for("message")
bin_package = search.package_from_scan(barcode)
if not bin_package:
- # TODO
- return self._response()
+ return self._response_for_scan_destination(
+ move_line, message=message.bin_not_found_for_barcode(barcode)
+ )
+
+ # the scanned package can contain only move lines of the same picking
+ if any(
+ ml.picking_id != move_line.picking_id
+ for ml in bin_package.planned_move_line_ids
+ ):
+ return self._response_for_scan_destination(
+ move_line,
+ message={
+ "message_type": "error",
+ "message": _(
+ "The destination bin {} is not empty, please take another."
+ ).format(bin_package.name),
+ },
+ )
+
move_line.write({"qty_done": quantity, "result_package_id": bin_package.id})
# TODO zero check
- # TODO handle next line and no next line (in a shared way with other
- # endpoints)
batch = move_line.picking_id.batch_id
- next_line = self._next_line_for_pick(batch)
- if not next_line:
- return self.prepare_unload(batch.id)
- return self._response(
- next_state="start_line",
- data=self._data_move_line(next_line),
+ return self._pick_next_line(
+ batch,
message={
"message_type": "success",
# TODO different message for products/packs?
@@ -549,7 +582,7 @@ def _data_for_unload_all(self, batch):
def _data_for_unload_single(self, batch, package):
line = fields.first(
- package.dest_move_line_ids.filtered(self._filter_for_unload)
+ package.planned_move_line_ids.filtered(self._filter_for_unload)
)
return {
# TODO disambiguate "id" everywhere? (id -> picking_batch_id)
@@ -645,12 +678,7 @@ def skip_line(self, move_line_id):
def _response_for_skip_line(self, move_line):
batch = move_line.picking_id.batch_id
- next_line = self._next_line_for_pick(batch)
- if not next_line:
- return self.prepare_unload(batch.id)
- return self._response(
- next_state="start_line", data=self._data_move_line(next_line)
- )
+ return self._pick_next_line(batch)
def stock_issue(self, move_line_id):
"""Declare a stock issue for a line
@@ -1096,11 +1124,16 @@ def unassign(self):
return self._response_schema(next_states=["start"])
def scan_line(self):
- return self._response_schema(next_states=["start_line", "scan_destination"])
+ return self._response_schema(
+ next_states=["start_line", "scan_destination", "start"]
+ )
def scan_destination_pack(self):
return self._response_schema(
next_states=[
+ "start",
+ # error during scan of pack (wrong barcode, ...)
+ "scan_destination",
# when we still have lines to process
"start_line",
# when the source location is empty
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index bf97800b41..f44f3b8a80 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -93,6 +93,14 @@ def _line_data(self, move_line, qty=None):
"pack": {"id": package.id, "name": package.name} if package else None,
}
+ @classmethod
+ def _set_dest_package_and_done(cls, move_lines, dest_package):
+ """Simulate what would have been done in the previous steps"""
+ for line in move_lines:
+ line.write(
+ {"qty_done": line.product_uom_qty, "result_package_id": dest_package.id}
+ )
+
class ClusterPickingAPICase(ClusterPickingCommonCase):
"""Base tests for the cluster picking API"""
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 1b258273cc..3e6d212d00 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -198,14 +198,19 @@ def setUpClass(cls, *args, **kwargs):
[cls.BatchProduct(product=cls.product_a, quantity=10)],
]
)
+ cls.one_line_picking = cls.batch.picking_ids[0]
+ cls.two_lines_picking = cls.batch.picking_ids[1]
+
cls.bin1 = cls.env["stock.quant.package"].create({})
+ cls.bin2 = cls.env["stock.quant.package"].create({})
+
+ cls._simulate_batch_selected(cls.batch)
def test_scan_destination_pack_ok(self):
"""Happy path for scan destination package
It sets the line in the pack for the full qty
"""
- self._simulate_batch_selected(self.batch)
line = self.batch.picking_ids.move_line_ids[0]
next_line = self.batch.picking_ids.move_line_ids[1]
qty_done = line.product_uom_qty
@@ -232,5 +237,173 @@ def test_scan_destination_pack_ok(self):
},
)
+ def test_scan_destination_pack_ok_last_line(self):
+ """Happy path for scan destination package
+
+ It sets the line in the pack for the full qty
+ """
+ self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1)
+ self._set_dest_package_and_done(
+ self.two_lines_picking.move_line_ids[0], self.bin2
+ )
+ # this is the only remaining line to pick
+ line = self.two_lines_picking.move_line_ids[1]
+ qty_done = line.product_uom_qty
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "move_line_id": line.id,
+ "barcode": self.bin2.name,
+ "quantity": qty_done,
+ },
+ )
+ self.assertRecordValues(
+ line, [{"qty_done": qty_done, "result_package_id": self.bin2.id}]
+ )
+ self.assert_response(
+ response,
+ # they reach the same destination so next state unload_all
+ next_state="unload_all",
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "location_dst": {
+ "id": self.packing_location.id,
+ "name": self.packing_location.name,
+ },
+ },
+ )
+
+ def test_scan_destination_pack_not_empty_same_picking(self):
+ """Scan a destination package with move lines of same picking"""
+ line1 = self.two_lines_picking.move_line_ids[0]
+ line2 = self.two_lines_picking.move_line_ids[1]
+ # we already scan and put the first line in bin1
+ self._set_dest_package_and_done(line1, self.bin1)
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "move_line_id": line2.id,
+ # this bin is used for the same picking, should be allowed
+ "barcode": self.bin1.name,
+ "quantity": line2.product_uom_qty,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ # we did not pick this line, so it should go there
+ data=self._line_data(self.one_line_picking.move_line_ids),
+ message=self.ANY,
+ )
+
+ def test_scan_destination_pack_not_empty_different_picking(self):
+ """Scan a destination package with move lines of other picking"""
+ # do as if the user already picked the first good (for another picking)
+ # and put it in bin1
+ self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1)
+ line = self.two_lines_picking.move_line_ids[0]
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "move_line_id": line.id,
+ # this bin is used for the other picking
+ "barcode": self.bin1.name,
+ "quantity": line.product_uom_qty,
+ },
+ )
+ self.assertRecordValues(line, [{"qty_done": 0, "result_package_id": False}])
+ self.assert_response(
+ response,
+ next_state="scan_destination",
+ data=self._line_data(line),
+ message={
+ "message_type": "error",
+ "message": "The destination bin {} is not empty,"
+ " please take another.".format(self.bin1.name),
+ },
+ )
+
+ def test_scan_destination_pack_bin_not_found(self):
+ """Scan a destination package that do not exist"""
+ line = self.one_line_picking.move_line_ids
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "move_line_id": line.id,
+ # this bin is used for the other picking
+ "barcode": "⌿",
+ "quantity": line.product_uom_qty,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="scan_destination",
+ data=self._line_data(line),
+ message={
+ "message_type": "error",
+ "message": "Bin {} doesn't exist".format("⌿"),
+ },
+ )
+
+ def test_scan_destination_pack_quantity_more(self):
+ """Pick more units than expected"""
+ line = self.one_line_picking.move_line_ids
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "move_line_id": line.id,
+ "barcode": self.bin1.name,
+ "quantity": line.product_uom_qty + 1,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="scan_destination",
+ data=self._line_data(line),
+ message={
+ "message_type": "error",
+ "message": "You must not pick more than {} units.".format(
+ line.product_uom_qty
+ ),
+ },
+ )
+
+ def test_scan_destination_pack_quantity_less(self):
+ """Pick less units than expected"""
+ line = self.one_line_picking.move_line_ids
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "move_line_id": line.id,
+ "barcode": self.bin1.name,
+ "quantity": line.product_uom_qty - 3,
+ },
+ )
+
+ self.assertRecordValues(
+ line,
+ [{"qty_done": 7, "result_package_id": self.bin1.id, "product_uom_qty": 7}],
+ )
+ new_line = self.one_line_picking.move_line_ids - line
+ self.assertRecordValues(
+ new_line,
+ [{"qty_done": 0, "result_package_id": False, "product_uom_qty": 3}],
+ )
+
+ self.assert_response(
+ response,
+ next_state="start_line",
+ # TODO ensure the duplicated line is the next line, it works now but
+ # maybe only by chance
+ data=self._line_data(new_line),
+ message={
+ "message_type": "success",
+ "message": "{} {} put in {}".format(
+ line.qty_done, line.product_id.display_name, self.bin1.name
+ ),
+ },
+ )
+
# TODO tests for transitions to next line / no next lines, ...
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index a801367eed..5668308fe2 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -32,14 +32,6 @@ def setUpClass(cls, *args, **kwargs):
}
)
- @classmethod
- def _set_dest_package_and_done(cls, move_lines, dest_package):
- """Simulate what would have been done in the previous steps"""
- for line in move_lines:
- line.write(
- {"qty_done": line.product_uom_qty, "result_package_id": dest_package.id}
- )
-
class ClusterPickingPrepareUnloadCase(ClusterPickingUnloadingCommonCase):
"""Tests covering the /prepare_unload endpoint
From cd911d76c23c525d3d3469f82fbb0b37148492f4 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 5 Mar 2020 16:04:53 +0100
Subject: [PATCH 137/940] cluster picking: add zero_check handling
---
shopfloor/actions/__init__.py | 1 +
shopfloor/actions/inventory.py | 30 +++++
shopfloor/actions/message.py | 8 ++
shopfloor/models/stock_move_line.py | 2 +
shopfloor/services/cluster_picking.py | 78 ++++++++++---
shopfloor/tests/common.py | 10 ++
shopfloor/tests/test_cluster_picking_scan.py | 115 ++++++++++++++++++-
7 files changed, 230 insertions(+), 14 deletions(-)
create mode 100644 shopfloor/actions/inventory.py
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index a6719c5d2f..c9bc8c3b8e 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -20,3 +20,4 @@
from . import message
from . import pack_transfer_validate
from . import search
+from . import inventory
diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py
new file mode 100644
index 0000000000..7feeb36d44
--- /dev/null
+++ b/shopfloor/actions/inventory.py
@@ -0,0 +1,30 @@
+from odoo import _
+
+from odoo.addons.component.core import Component
+
+
+class InventoryAction(Component):
+ """Provide methods to work with inventories
+
+ Several processes have to create inventories at some point,
+ for instance when there is a stock issue.
+ """
+
+ _name = "shopfloor.inventory.action"
+ _inherit = "shopfloor.process.action"
+ _usage = "inventory"
+
+ def create_draft_check_empty(self, location, product, ref=None):
+ """Create a draft inventory for a product with a zero quantity"""
+ if ref:
+ name = _("Zero check issue on location {} ({})").format(location.name, ref)
+ else:
+ name = _("Zero check issue on location {}").format(location.name)
+ inventory = self.env["stock.inventory"].create(
+ {
+ "name": name,
+ "location_ids": [(6, 0, location.ids)],
+ "product_ids": [(6, 0, product.ids)],
+ }
+ )
+ return inventory
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index b6bde733ad..f338c46658 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -158,3 +158,11 @@ def unrecoverable_error(self):
"message_type": "error",
"message": _("Unrecoverable error, please restart."),
}
+
+ def x_units_put_in_package(self, qty, product, package):
+ return {
+ "message_type": "success",
+ "message": _("{} {} put in {}").format(
+ qty, product.display_name, package.name
+ ),
+ }
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index b829cb8d85..f205aeaa01 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -11,3 +11,5 @@ class StockMoveLine(models.Model):
help="Technical field. "
"Indicates if a the move has been postponed in a process.",
)
+ # we search lines based on their location in some workflows
+ location_id = fields.Many2one(index=True)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 69d8914567..88b0f8e15a 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1,5 +1,5 @@
from odoo import _, fields
-from odoo.tools.float_utils import float_compare # , float_is_zero
+from odoo.tools.float_utils import float_compare
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
@@ -214,7 +214,6 @@ def confirm_start(self, picking_batch_id):
picking_batch = self.env["stock.picking.batch"].browse(picking_batch_id)
if not picking_batch.exists():
return self._response_batch_does_not_exist()
-
return self._pick_next_line(picking_batch)
def _pick_next_line(self, batch, message=None):
@@ -521,20 +520,52 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
).format(bin_package.name),
},
)
-
move_line.write({"qty_done": quantity, "result_package_id": bin_package.id})
- # TODO zero check
+
batch = move_line.picking_id.batch_id
+
+ if self._planned_qty_in_location_is_empty(
+ move_line.product_id, move_line.location_id
+ ):
+ return self._response_for_zero_check(move_line)
+
return self._pick_next_line(
batch,
- message={
- "message_type": "success",
- # TODO different message for products/packs?
- "message": _("{} {} put in {}").format(
- move_line.qty_done,
- move_line.product_id.display_name,
- bin_package.name,
- ),
+ message=message.x_units_put_in_package(
+ move_line.qty_done, move_line.product_id, move_line.result_package_id
+ ),
+ )
+
+ def _planned_qty_in_location_is_empty(self, product, location):
+ """Return if a location will be empty when move lines will be confirmed
+
+ Used for the "zero check". We need to know if a location is empty, but since
+ we set the move lines to "done" only at the end of the unload workflow, we
+ have to look at the qty_done of the move lines from this location.
+ """
+ remaining = product.with_context(location=location.id).qty_available
+ lines_in_loc = self.env["stock.move.line"].search(
+ # TODO do we care about lots here?
+ [
+ ("state", "!=", "done"),
+ ("location_id", "=", location.id),
+ ("product_id", "=", product.id),
+ ]
+ )
+ planned = remaining - sum(lines_in_loc.mapped("qty_done"))
+ rounding = product.uom_id.rounding
+ compare = float_compare(planned, 0, precision_rounding=rounding)
+ return compare <= 0
+
+ def _response_for_zero_check(self, move_line):
+ return self._response(
+ next_state="zero_check",
+ data={
+ "id": move_line.id,
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
},
)
@@ -652,7 +683,28 @@ def is_zero(self, move_line_id, zero):
* unload_single: if all lines have a destination package and different
destination
"""
- return self._response()
+ message = self.actions_for("message")
+ move_line = self.env["stock.move.line"].browse(move_line_id)
+ if not move_line.exists():
+ return self._response(
+ next_state="start", message=message.unrecoverable_error()
+ )
+
+ if not zero:
+ inventory = self.actions_for("inventory")
+ inventory.create_draft_check_empty(
+ move_line.location_id,
+ move_line.product_id,
+ ref=move_line.picking_id.name,
+ )
+
+ batch = move_line.picking_id.batch_id
+ return self._pick_next_line(
+ batch,
+ message=message.x_units_put_in_package(
+ move_line.qty_done, move_line.product_id, move_line.result_package_id
+ ),
+ )
def skip_line(self, move_line_id):
"""Skip a line. The line will be processed at the end.
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 8f4a5e5925..fe4dbeeba4 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -94,6 +94,11 @@ def assert_response(self, response, next_state=None, message=None, data=None):
def _update_qty_in_location(
cls, location, product, quantity, package=None, lot=None
):
+ quants = cls.env["stock.quant"]._gather(
+ product, location, lot_id=lot, package_id=package, strict=True
+ )
+ # this method adds the quantity to the current quantity, so remove it
+ quantity -= sum(quants.mapped("quantity"))
cls.env["stock.quant"]._update_available_quantity(
product, location, quantity, package_id=package, lot_id=lot
)
@@ -114,6 +119,11 @@ def _fill_stock_for_moves(cls, moves, in_package=False, in_lot=False):
lot = cls.env["stock.production.lot"].create(
{"product_id": product.id, "company_id": cls.env.company.id}
)
+ else:
+ # always add more quantity in stock to avoid to trigger the
+ # "zero checks" in tests, not for lots which must have a qty
+ # of 1
+ qty *= 2
cls._update_qty_in_location(
location, product, qty, package=package, lot=lot
)
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 3e6d212d00..93682b07ff 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -405,5 +405,118 @@ def test_scan_destination_pack_quantity_less(self):
},
)
+ def test_scan_destination_pack_zero_check(self):
+ """Location will be emptied, have to go to zero check"""
+ line = self.one_line_picking.move_line_ids
+ # Update the quantity in the location to be equal to the line's
+ # so when scan_destination_pack sets the qty_done, the planned
+ # qty should be zero and trigger a zero check
+ self._update_qty_in_location(
+ line.location_id, line.product_id, line.product_uom_qty
+ )
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "move_line_id": line.id,
+ "barcode": self.bin1.name,
+ "quantity": line.product_uom_qty,
+ },
+ )
+
+ self.assert_response(
+ response,
+ next_state="zero_check",
+ data={
+ "id": line.id,
+ "location_src": {
+ "id": line.location_id.id,
+ "name": line.location_id.name,
+ },
+ },
+ )
+
+
+class ClusterPickingIsZeroCase(ClusterPickingCommonCase):
+ """Tests covering the /is_zero endpoint
+
+ After a line has been scanned, if the location is empty, the
+ client application is redirected to the "zero_check" state,
+ where the user has to confirm or not that the location is empty.
+ When the location is empty, there is nothing to do, but when it
+ in fact not empty, a draft inventory must be created for the
+ product so someone can verify.
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.batch = cls._create_picking_batch(
+ [
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10),
+ cls.BatchProduct(product=cls.product_b, quantity=10),
+ ]
+ ]
+ )
+ cls.picking = cls.batch.picking_ids
+ cls._simulate_batch_selected(cls.batch)
+
+ cls.line = cls.picking.move_line_ids[0]
+ cls.next_line = cls.picking.move_line_ids[1]
+ cls.bin1 = cls.env["stock.quant.package"].create({})
+ cls._update_qty_in_location(
+ cls.line.location_id, cls.line.product_id, cls.line.product_uom_qty
+ )
+ # we already scan and put the first line in bin1, at this point the
+ # system see the location is empty and reach "zero_check"
+ cls._set_dest_package_and_done(cls.line, cls.bin1)
+
+ def test_is_zero_is_empty(self):
+ """call /is_zero confirming it's empty"""
+ response = self.service.dispatch(
+ "is_zero", params={"move_line_id": self.line.id, "zero": True}
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(self.next_line),
+ message={
+ "message_type": "success",
+ "message": "{} {} put in {}".format(
+ self.line.qty_done,
+ self.line.product_id.display_name,
+ self.bin1.name,
+ ),
+ },
+ )
-# TODO tests for transitions to next line / no next lines, ...
+ def test_is_zero_is_not_empty(self):
+ """call /is_zero not confirming it's empty"""
+ response = self.service.dispatch(
+ "is_zero", params={"move_line_id": self.line.id, "zero": False}
+ )
+ inventory = self.env["stock.inventory"].search(
+ [
+ ("location_ids", "in", self.line.location_id.id),
+ ("product_ids", "in", self.line.product_id.id),
+ ("state", "=", "draft"),
+ ]
+ )
+ self.assertTrue(inventory)
+ self.assertEqual(
+ inventory.name,
+ "Zero check issue on location Stock ({})".format(self.picking.name),
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(self.next_line),
+ message={
+ "message_type": "success",
+ "message": "{} {} put in {}".format(
+ self.line.qty_done,
+ self.line.product_id.display_name,
+ self.bin1.name,
+ ),
+ },
+ )
From c6d7641f87a1ecceb2779c249550949ad476a4b5 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 5 Mar 2020 16:36:19 +0100
Subject: [PATCH 138/940] rest validators: add an implicit start state
Since all the endpoints could theoretically bring back to the start
state in case of unrecoverable error, we should not need to add the
state everywhere. It's still fine and probably better to keep the
start state in the possible next state of a state when it's the logical
next state (not because of an error) for documentation.
---
shopfloor/services/checkout.py | 20 ++++---
shopfloor/services/cluster_picking.py | 64 +++++++++-------------
shopfloor/services/single_pack_putaway.py | 8 +--
shopfloor/services/single_pack_transfer.py | 10 ++--
shopfloor/services/validator.py | 16 ++++++
5 files changed, 59 insertions(+), 59 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 6e2c95d8b4..77e5acea3e 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -270,6 +270,8 @@ class ShopfloorCheckoutValidatorResponse(Component):
_name = "shopfloor.checkout.validator.response"
_usage = "checkout.validator.response"
+ _start_state = "select_document"
+
def _states(self):
"""List of possible next states
@@ -290,35 +292,35 @@ def _states(self):
}
def scan_document(self):
- return self._response_schema(next_states=["select_document", "select_line"])
+ return self._response_schema(next_states={"select_document", "select_line"})
def list_stock_picking(self):
- return self._response_schema(next_states=["manual_selection"])
+ return self._response_schema(next_states={"manual_selection"})
def select(self):
return self._response_schema(
- next_states=["manual_selection", "summary", "select_line"]
+ next_states={"manual_selection", "summary", "select_line"}
)
def scan_line(self):
- return self._response_schema(next_states=["select_line", "select_pack"])
+ return self._response_schema(next_states={"select_line", "select_pack"})
def select_line(self):
return self.scan_line()
def reset_line_qty(self):
- return self._response_schema(next_states=["select_pack"])
+ return self._response_schema(next_states={"select_pack"})
def set_line_qty(self):
- return self._response_schema(next_states=["select_pack"])
+ return self._response_schema(next_states={"select_pack"})
def scan_pack_action(self):
return self._response_schema(
- next_states=["select_pack", "select_line", "summary"]
+ next_states={"select_pack", "select_line", "summary"}
)
def set_custom_qty(self):
- return self._response_schema(next_states=["select_pack"])
+ return self._response_schema(next_states={"select_pack"})
def new_package(self):
- return self._response_schema(next_states=["select_line"])
+ return self._response_schema(next_states={"select_line"})
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 88b0f8e15a..e0fd21f207 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1143,24 +1143,18 @@ def _states(self):
}
def find_batch(self):
- return self._response_schema(next_states=["confirm_start", "start"])
+ return self._response_schema(next_states={"confirm_start"})
def list_batch(self):
- return self._response_schema(next_states=["manual_selection"])
+ return self._response_schema(next_states={"manual_selection"})
def select(self):
- return self._response_schema(next_states=["manual_selection", "confirm_start"])
+ return self._response_schema(next_states={"manual_selection", "confirm_start"})
def confirm_start(self):
return self._response_schema(
- next_states=[
+ next_states={
"start_line",
- # "start" should be pretty rare, only if the batch has been
- # canceled, deleted meanwhile...
- # TODO every state could bring back to 'start' in case of
- # unrecoverable error, maybe we should add an attribute
- # `_start_state = "start"` and implicitly add it in states
- "start",
# we reopen a batch already started where all the lines were
# already picked and have to be unloaded to the same
# destination
@@ -1169,21 +1163,18 @@ def confirm_start(self):
# already picked and have to be unloaded to the different
# destinations
"unload_single",
- ]
+ }
)
def unassign(self):
- return self._response_schema(next_states=["start"])
+ return self._response_schema(next_states={"start"})
def scan_line(self):
- return self._response_schema(
- next_states=["start_line", "scan_destination", "start"]
- )
+ return self._response_schema(next_states={"start_line", "scan_destination"})
def scan_destination_pack(self):
return self._response_schema(
- next_states=[
- "start",
+ next_states={
# error during scan of pack (wrong barcode, ...)
"scan_destination",
# when we still have lines to process
@@ -1196,24 +1187,24 @@ def scan_destination_pack(self):
# when all lines have been processed and have different
# destinations
"unload_single",
- ]
+ }
)
def prepare_unload(self):
return self._response_schema(
- next_states=[
+ next_states={
# when all lines have been processed and have same
# destination
"unload_all",
# when all lines have been processed and have different
# destinations
"unload_single",
- ]
+ }
)
def is_zero(self):
return self._response_schema(
- next_states=[
+ next_states={
# when we still have lines to process
"start_line",
# when all lines have been processed and have same
@@ -1222,15 +1213,15 @@ def is_zero(self):
# when all lines have been processed and have different
# destinations
"unload_single",
- ]
+ }
)
def skip_line(self):
- return self._response_schema(next_states=["start_line"])
+ return self._response_schema(next_states={"start_line"})
def stock_issue(self):
return self._response_schema(
- next_states=[
+ next_states={
# when we still have lines to process
"start_line",
# when all lines have been processed and have same
@@ -1239,16 +1230,15 @@ def stock_issue(self):
# when all lines have been processed and have different
# destinations
"unload_single",
- "start",
- ]
+ }
)
def change_pack_lot(self):
- return self._response_schema(next_states=["scan_destination", "start_line"])
+ return self._response_schema(next_states={"scan_destination", "start_line"})
def set_destination_all(self):
return self._response_schema(
- next_states=[
+ next_states={
# if the batch still contain lines
"start_line",
# invalid destination, have to scan a valid one
@@ -1260,20 +1250,18 @@ def set_destination_all(self):
"confirm_unload_all",
# batch finished
"start",
- ]
+ }
)
def unload_split(self):
- return self._response_schema(next_states=["unload_single"])
+ return self._response_schema(next_states={"unload_single"})
def unload_router(self):
- return self._response_schema(
- next_states=["unload_single", "start_line", "start"]
- )
+ return self._response_schema(next_states={"unload_single", "start_line"})
def unload_scan_pack(self):
return self._response_schema(
- next_states=[
+ next_states={
# go back to the same state if barcode issue
"unload_single",
# if the package to scan was deleted, was the last to unload
@@ -1281,21 +1269,19 @@ def unload_scan_pack(self):
"start_line",
# next "logical" state, when the scan is ok
"unload_set_destination",
- # the package was deleted and was the last one of the batch
- "start",
- ]
+ }
)
def unload_scan_destination(self):
return self._response_schema(
- next_states=[
+ next_states={
"unload_single",
"unload_set_destination",
"confirm_unload_set_destination",
"show_completion_info",
"start",
"start_line",
- ]
+ }
)
# TODO single class for sharing schemas between services
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 70cdfdc92a..9c2857d348 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -306,17 +306,15 @@ def _states(self):
}
def cancel(self):
- return self._response_schema(next_states=["start"])
+ return self._response_schema(next_states={"start"})
def validate(self):
return self._response_schema(
- next_states=["scan_location", "start", "confirm_location"]
+ next_states={"scan_location", "start", "confirm_location"}
)
def start(self):
- return self._response_schema(
- next_states=["confirm_start", "start", "scan_location"]
- )
+ return self._response_schema(next_states={"confirm_start", "scan_location"})
@property
def _schema_for_location(self):
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 01f5c8b5f0..edef7da3b1 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -272,21 +272,19 @@ def _states(self):
}
def start(self):
- return self._response_schema(
- next_states=["confirm_start", "start", "scan_location"]
- )
+ return self._response_schema(next_states={"confirm_start", "scan_location"})
def cancel(self):
- return self._response_schema(next_states=["start"])
+ return self._response_schema(next_states={"start"})
def validate(self):
return self._response_schema(
- next_states=[
+ next_states={
"scan_location",
"start",
"confirm_location",
"show_completion_info",
- ]
+ }
)
@property
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
index f4e1e3373c..b7db691361 100644
--- a/shopfloor/services/validator.py
+++ b/shopfloor/services/validator.py
@@ -55,6 +55,9 @@ class BaseShopfloorValidatorResponse(AbstractComponent):
_collection = "shopfloor.service"
_is_rest_service_component = False
+ # Initial state of a workflow
+ _start_state = "start"
+
def _states(self):
"""List of possible next states
@@ -73,6 +76,10 @@ def _response_schema(self, data_schema=None, next_states=None):
next_states is a list of allowed states to which the client
can transition. The schema of the data needed for every state
of the list must be defined in the ``_states`` method.
+
+ The initial state does not need to be included in the list, it
+ is implicit as we assume that any state can go back to the initial
+ state in case of unrecoverable error.
"""
response_schema = {
"message": {
@@ -92,7 +99,16 @@ def _response_schema(self, data_schema=None, next_states=None):
data_schema = {}
if next_states:
+ next_states = set(next_states)
+ next_states.add(self._start_state)
states_schemas = self._states()
+ if self._start_state not in states_schemas:
+ raise ValueError(
+ "the _start_state is {} but this state does not exist"
+ ", you may want to change the property's value".format(
+ self._start_state
+ )
+ )
unknown_states = set(next_states) - states_schemas.keys()
if unknown_states:
raise ValueError(
From 60f4d2d6a10cdf8b6a8fe0dd8577c984084622b5 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 6 Mar 2020 16:22:19 +0100
Subject: [PATCH 139/940] cluster picking: implement skipped test
---
shopfloor/tests/test_cluster_picking_select.py | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index 63b05e04e7..af742af033 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -1,5 +1,3 @@
-import unittest
-
from .test_cluster_picking_base import ClusterPickingCommonCase
@@ -414,11 +412,22 @@ def test_confirm_start_not_exists(self):
next_state="start",
)
- # TODO
- @unittest.skip("not sure yet what we have to do, keep for later")
def test_confirm_start_all_is_done(self):
"""User confirms start but all lines are already done"""
# we want to jump to the start because there are no lines
# to process anymore, but we want to set pickings and
# picking batch to done if not done yet (because the process
# was interrupted for instance)
+ self._set_dest_package_and_done(
+ self.batch.mapped("picking_ids.move_line_ids"),
+ self.env["stock.quant.package"].create({}),
+ )
+ self.batch.done()
+ response = self.service.dispatch(
+ "confirm_start", params={"picking_batch_id": self.batch.id}
+ )
+ self.assert_response(
+ response,
+ next_state="start",
+ message={"message": "Batch Transfer complete", "message_type": "success"},
+ )
From 8ac223596390aa32055afe6c8e1a41df38443b23 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 11 Mar 2020 15:26:21 +0100
Subject: [PATCH 140/940] checkout: more specs, and add a common component for
schemas
we'll still have to refine the format of the schemas and to adapt
the workflows to use shared schemas
---
shopfloor/actions/__init__.py | 1 +
shopfloor/actions/data.py | 13 ++
shopfloor/services/__init__.py | 1 +
shopfloor/services/checkout.py | 342 ++++++++++++++++++++++++++++----
shopfloor/services/schema.py | 75 +++++++
shopfloor/services/validator.py | 3 +
6 files changed, 401 insertions(+), 34 deletions(-)
create mode 100644 shopfloor/actions/data.py
create mode 100644 shopfloor/services/schema.py
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index c9bc8c3b8e..b98bf56e07 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -17,6 +17,7 @@
"""
from . import base_action
+from . import data
from . import message
from . import pack_transfer_validate
from . import search
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
new file mode 100644
index 0000000000..ee2b903950
--- /dev/null
+++ b/shopfloor/actions/data.py
@@ -0,0 +1,13 @@
+from odoo.addons.component.core import Component
+
+
+class DataAction(Component):
+ """Provide methods to share data structures
+
+ The methods should be used in Service Components, so we try to
+ have similar data structures across scenarios.
+ """
+
+ _name = "shopfloor.data.action"
+ _inherit = "shopfloor.process.action"
+ _usage = "data"
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 6a8054d8e0..34f9243f4f 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -1,6 +1,7 @@
# core classes
from . import service
from . import validator
+from . import schema
# generic services
from . import app
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 77e5acea3e..bb2d2f4083 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -98,7 +98,7 @@ def scan_line(self, picking_id, barcode):
Transitions:
* select_line: nothing could be found for the barcode
- * select_pack: lines are selected, user is redirected to this
+ * select_package: lines are selected, user is redirected to this
screen to change the qty done and destination pack if needed
"""
return self._response()
@@ -110,41 +110,41 @@ def select_line(self, picking_id, package_id=None, move_line_id=None):
move_line_id is given by the client (user clicked on a list).
It returns a list of move line ids that will be displayed by the
- screen ``select_pack``. This screen will have to send this list to
+ screen ``select_package``. This screen will have to send this list to
the endpoints it calls, so we can select/deselect lines but still
show them in the list of the client application.
Transitions:
* select_line: nothing could be found for the barcode
- * select_pack: lines are selected, user is redirected to this
- screen to change the qty done and destination pack if needed
+ * select_package: lines are selected, user is redirected to this
+ screen to change the qty done and destination package if needed
"""
assert package_id or move_line_id
return self._response()
- def reset_line_qty(self, move_line_id):
+ def reset_line_qty(self, picking_id, move_line_id):
"""Reset qty_done of a move line to zero
- Used to deselect a line in the "select_pack" screen.
+ Used to deselect a line in the "select_package" screen.
Transitions:
- * select_pack: goes back to the same state, the line will appear
+ * select_package: goes back to the same state, the line will appear
as deselected
"""
return self._response()
- def set_line_qty(self, move_line_id):
+ def set_line_qty(self, picking_id, move_line_id):
"""Set qty_done of a move line to its reserved quantity
- Used to deselect a line in the "select_pack" screen.
+ Used to deselect a line in the "select_package" screen.
Transitions:
- * select_pack: goes back to the same state, the line will appear
+ * select_package: goes back to the same state, the line will appear
as selected
"""
return self._response()
- def scan_pack_action(self, move_line_ids, barcode):
+ def scan_package_action(self, picking_id, move_line_ids, barcode):
"""Scan a package, a lot, a product or a package to handle a line
When a package is scanned, if the package is known as the destination
@@ -166,7 +166,7 @@ def scan_pack_action(self, move_line_ids, barcode):
``qty_done`` > 0 and have no destination package.
Transitions:
- * select_pack: when a product or lot is scanned to select/deselect,
+ * select_package: when a product or lot is scanned to select/deselect,
the client app has to show the same screen with the updated selection
* select_line: when a package or packaging type is scanned, move lines
have been put in package and we can return back to this state to handle
@@ -176,16 +176,16 @@ def scan_pack_action(self, move_line_ids, barcode):
"""
return self._response()
- def set_custom_qty(self, move_line_id, qty_done):
+ def set_custom_qty(self, picking_id, move_line_id, qty_done):
"""Change qty_done of a move line with a custom value
Transitions:
- * select_pack: goes back to this screen showing all the lines after
+ * select_package: goes back to this screen showing all the lines after
we changed the qty
"""
return self._response()
- def new_package(self, move_line_ids):
+ def new_package(self, picking_id, move_line_ids):
"""Add all selected lines in a new package
It creates a new package and set it as the destination package of all
@@ -199,7 +199,102 @@ def new_package(self, move_line_ids):
"""
return self._response()
- # TODO add the rest of the methods
+ def list_dest_package(self, picking_id, move_line_ids):
+ """Return a list of packages the user can select for the lines
+
+ Only valid packages must be proposed. Look at ``scan_dest_package``
+ for the conditions to be valid.
+
+ Transitions:
+ * select_dest_package: selection screen
+ """
+ return self._response()
+
+ def scan_dest_package(self, picking_id, move_line_ids, barcode):
+ """Scan destination package for lines
+
+ Set the destination package on the selected lines with a `qty_done` if
+ the package is valid. It is valid when one of:
+
+ * it is already the destination package of another line of the stock.picking
+ * it is the source package of the selected lines
+
+ Note: by default, Odoo puts the same destination package as the source
+ package on lines.
+
+ Transitions:
+ * select_package: error when scanning package
+ * select_line: lines to package remain
+ * summary: all lines are put in packages
+ """
+ return self._response()
+
+ def set_dest_package(self, picking_id, move_line_ids, package_id):
+ """Set destination package for lines from a package id
+
+ Used by the list obtained from ``list_dest_package``.
+
+ The validity is the same as ``scan_dest_package``.
+
+ Transitions:
+ * select_dest_package: error when scanning package
+ * select_line: lines to package remain
+ * summary: all lines are put in packages
+ """
+ return self._response()
+
+ def summary(self, picking_id):
+ """Return information for the summary screen
+
+ Transitions:
+ * summary
+ """
+ return self._response()
+
+ def list_package_type(self, picking_id, package_id):
+ """List the available package types for a package
+
+ For a package, we can change the package type. The available
+ package types are the ones with no product.
+
+ Transitions:
+ * change_package_type
+ """
+ return self._response()
+
+ def set_package_type(self, picking_id, package_id, package_type_id):
+ """Set a package type on a package
+
+ Transitions:
+ * change_package_type: in case of error
+ * summary
+ """
+ return self._response()
+
+ def remove_package(self, picking_id, package_id):
+ """Remove destination package from move lines and set qty done to 0
+
+ All the move lines with the package as ``result_package_id`` have their
+ ``result_package_id`` reset to the source package (default odoo behavior)
+ and their ``qty_done`` set to 0.
+
+ Transitions:
+ * summary
+ """
+ return self._response()
+
+ def done(self, picking_id, confirmation=False):
+ """Set the moves as done
+
+ If some lines have not the full ``qty_done`` or no destination package set,
+ a confirmation is asked to the user.
+
+ Transitions:
+ * summary: in case of error
+ * select_document: after done, goes back to start
+ * confirm_done: confirm a partial
+ """
+ return self._response()
class ShopfloorCheckoutValidator(Component):
@@ -232,13 +327,20 @@ def select_line(self):
}
def reset_line_qty(self):
- return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}}
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
def set_line_qty(self):
- return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}}
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
- def scan_pack_action(self):
+ def scan_package_action(self):
return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
"move_line_ids": {
"type": "list",
"required": True,
@@ -249,17 +351,79 @@ def scan_pack_action(self):
def set_custom_qty(self):
return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
"qty_done": {"coerce": to_float, "required": True, "type": "float"},
}
def new_package(self):
return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
"move_line_ids": {
"type": "list",
"required": True,
"schema": {"coerce": to_int, "required": True, "type": "integer"},
- }
+ },
+ }
+
+ def list_dest_package(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_ids": {
+ "type": "list",
+ "required": True,
+ "schema": {"coerce": to_int, "required": True, "type": "integer"},
+ },
+ }
+
+ def scan_dest_package(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_ids": {
+ "type": "list",
+ "required": True,
+ "schema": {"coerce": to_int, "required": True, "type": "integer"},
+ },
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def set_dest_package(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_ids": {
+ "type": "list",
+ "required": True,
+ "schema": {"coerce": to_int, "required": True, "type": "integer"},
+ },
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def summary(self):
+ return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def list_package_type(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def set_package_type(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def remove_package(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def done(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
}
@@ -278,17 +442,99 @@ def _states(self):
With the schema of the data send to the client to transition
to the next state.
"""
- # TODO schemas
return {
"select_document": {},
"manual_selection": {},
- "select_line": {},
- "select_pack": {},
- "change_quantity": {},
- "select_dest_package": {},
- "summary": {},
- "change_package_type": {},
- "confirm_done": {},
+ "select_line": self._schema_picking_details,
+ "select_package": self._schema_selected_lines,
+ "change_quantity": self._schema_selected_lines,
+ "select_dest_package": self._schema_select_package,
+ "summary": self._schema_picking_details,
+ "change_package_type": self._schema_select_package_type,
+ "confirm_done": self._schema_picking_details,
+ }
+
+ @property
+ def _schema_picking_details(self):
+ return {
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "origin": {"type": "string", "nullable": True, "required": True},
+ "note": {"type": "string", "nullable": True, "required": True},
+ "move_lines": {
+ "type": "list",
+ "schema": {
+ "type": "dict",
+ "schema": self.schemas().move_line(),
+ },
+ },
+ },
+ }
+ }
+
+ @property
+ def _schema_select_package(self):
+ return {
+ "selected_move_lines": {
+ "type": "list",
+ "schema": {"type": "dict", "schema": self.schemas().move_line()},
+ },
+ "packages": {
+ "type": "list",
+ "schema": {"type": "dict", "schema": self.schemas().package()},
+ },
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "origin": {"type": "string", "nullable": True, "required": True},
+ "note": {"type": "string", "nullable": True, "required": True},
+ },
+ },
+ }
+
+ @property
+ def _schema_select_package_type(self):
+ return {
+ "selected_move_lines": {
+ "type": "list",
+ "schema": {"type": "dict", "schema": self.schemas().move_line()},
+ },
+ "package_types": {
+ "type": "list",
+ "schema": {"type": "dict", "schema": self.schemas().package_type()},
+ },
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "origin": {"type": "string", "nullable": True, "required": True},
+ "note": {"type": "string", "nullable": True, "required": True},
+ },
+ },
+ }
+
+ @property
+ def _schema_selected_lines(self):
+ return {
+ "selected_move_lines": {
+ "type": "list",
+ "schema": {"type": "dict", "schema": self.schemas().move_line()},
+ },
+ "picking": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "origin": {"type": "string", "nullable": True, "required": True},
+ "note": {"type": "string", "nullable": True, "required": True},
+ },
+ },
}
def scan_document(self):
@@ -303,24 +549,52 @@ def select(self):
)
def scan_line(self):
- return self._response_schema(next_states={"select_line", "select_pack"})
+ return self._response_schema(next_states={"select_line", "select_package"})
def select_line(self):
return self.scan_line()
def reset_line_qty(self):
- return self._response_schema(next_states={"select_pack"})
+ return self._response_schema(next_states={"select_package"})
def set_line_qty(self):
- return self._response_schema(next_states={"select_pack"})
+ return self._response_schema(next_states={"select_package"})
- def scan_pack_action(self):
+ def scan_package_action(self):
return self._response_schema(
- next_states={"select_pack", "select_line", "summary"}
+ next_states={"select_package", "select_line", "summary"}
)
def set_custom_qty(self):
- return self._response_schema(next_states={"select_pack"})
+ return self._response_schema(next_states={"select_package"})
def new_package(self):
return self._response_schema(next_states={"select_line"})
+
+ def list_dest_package(self):
+ return self._response_schema(next_states={"select_dest_package"})
+
+ def scan_dest_package(self):
+ return self._response_schema(
+ next_states={"select_package", "select_line", "summary"}
+ )
+
+ def set_dest_package(self):
+ return self._response_schema(
+ next_states={"select_dest_package", "select_line", "summary"}
+ )
+
+ def summary(self):
+ return self._response_schema(next_states={"summary"})
+
+ def list_package_type(self):
+ return self._response_schema(next_states={"change_package_type"})
+
+ def set_package_type(self):
+ return self._response_schema(next_states={"change_package_type", "summary"})
+
+ def remove_package(self):
+ return self._response_schema(next_states={"summary"})
+
+ def done(self):
+ return self._response_schema(next_states={"summary", "confirm_done"})
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
new file mode 100644
index 0000000000..8d05bd4f02
--- /dev/null
+++ b/shopfloor/services/schema.py
@@ -0,0 +1,75 @@
+from odoo.addons.component.core import Component
+
+
+class BaseShopfloorSchemaResponse(Component):
+ """Provide methods to share schema structures
+
+ The methods should be used in Service Components, so we try to
+ have similar schema structures across scenarios.
+ """
+
+ _inherit = "base.rest.service"
+ _name = "base.shopfloor.schemas"
+ _collection = "shopfloor.service"
+ _usage = "schema"
+ _is_rest_service_component = False
+
+ def move_line(self):
+ return {
+ "id": {"type": "integer", "required": True},
+ "qty_done": {"type": "float", "required": True},
+ "quantity": {"type": "float", "required": True},
+ "product": {"type": "dict", "required": True, "schema": self.product()},
+ "lot": {"type": "dict", "required": True, "schema": self.lot()},
+ "package_src": {"type": "dict", "required": True, "schema": self.package()},
+ "package_dest": {
+ "type": "dict",
+ "required": True,
+ "schema": self.package(),
+ },
+ "location_src": {
+ "type": "dict",
+ "required": True,
+ "schema": self.location(),
+ },
+ "location_dest": {
+ "type": "dict",
+ "required": True,
+ "schema": self.location(),
+ },
+ }
+
+ def product(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "display_name": {"type": "string", "nullable": False, "required": True},
+ "default_code": {"type": "string", "nullable": False, "required": True},
+ }
+
+ def package(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "weight": {"required": True, "nullable": True, "type": "float"},
+ "line_count": {"required": True, "nullable": True, "type": "integer"},
+ "package_type": {"required": True, "nullable": True, "type": "string"},
+ }
+
+ def lot(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ }
+
+ def location(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ }
+
+ def package_type(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ }
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
index b7db691361..db6d3bb3df 100644
--- a/shopfloor/services/validator.py
+++ b/shopfloor/services/validator.py
@@ -66,6 +66,9 @@ def _states(self):
"""
return {}
+ def schemas(self):
+ return self.component(usage="schema")
+
def _response_schema(self, data_schema=None, next_states=None):
"""Schema for the return validator
From edbd1780ef3d3fa1ee050c4ce9e88fb25cfcf1b7 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 12 Mar 2020 13:06:20 +0100
Subject: [PATCH 141/940] Start checkout
---
shopfloor/demo/shopfloor_menu_demo.xml | 6 ++
shopfloor/demo/shopfloor_process_demo.xml | 6 ++
shopfloor/demo/stock_picking_type_demo.xml | 16 +++++
shopfloor/models/shopfloor_process.py | 1 +
shopfloor/services/checkout.py | 18 +++--
shopfloor/services/schema.py | 2 +-
shopfloor/tests/test_checkout.py | 77 +++++++++++++++++++++-
7 files changed, 118 insertions(+), 8 deletions(-)
diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml
index f19f603dfb..84e8e4f0c4 100644
--- a/shopfloor/demo/shopfloor_menu_demo.xml
+++ b/shopfloor/demo/shopfloor_menu_demo.xml
@@ -18,4 +18,10 @@
+
+
diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml
index 660079c145..844ec09df6 100644
--- a/shopfloor/demo/shopfloor_process_demo.xml
+++ b/shopfloor/demo/shopfloor_process_demo.xml
@@ -18,4 +18,10 @@
+
+ Checkout
+ checkout
+
+
+
diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml
index 653cac7759..079bf0fb83 100644
--- a/shopfloor/demo/stock_picking_type_demo.xml
+++ b/shopfloor/demo/stock_picking_type_demo.xml
@@ -48,4 +48,20 @@
+
+ Checkout
+ CHK
+
+
+
+
+
+
+
+
+ internal
+
+
+
+
diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py
index bff78f93e3..1492df892d 100644
--- a/shopfloor/models/shopfloor_process.py
+++ b/shopfloor/models/shopfloor_process.py
@@ -18,4 +18,5 @@ def _selection_code(self):
("single_pack_putaway", "Single Pack Put-away"),
("single_pack_transfer", "Single Pack Transfer"),
("cluster_picking", "Cluster Picking"),
+ ("checkout", "Checkout"),
]
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index bb2d2f4083..0e7eba6bb7 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -3,6 +3,10 @@
from .service import to_float
+# NOTE: we need to know if the destination package is set, but sometimes
+# the dest. package is kept, so we should have an additional field on
+# move lines to keep track of lines set
+
class Checkout(Component):
"""
@@ -48,6 +52,8 @@ def scan_document(self, barcode):
Transitions:
* select_document: when no stock.picking could be found
* select_line: a stock.picking is selected
+ * summary: stock.picking is selected and all its lines have a
+ destination pack set
"""
return self._response()
@@ -445,17 +451,17 @@ def _states(self):
return {
"select_document": {},
"manual_selection": {},
- "select_line": self._schema_picking_details,
+ "select_line": self._schema_stock_picking_details,
"select_package": self._schema_selected_lines,
"change_quantity": self._schema_selected_lines,
"select_dest_package": self._schema_select_package,
- "summary": self._schema_picking_details,
+ "summary": self._schema_stock_picking_details,
"change_package_type": self._schema_select_package_type,
- "confirm_done": self._schema_picking_details,
+ "confirm_done": self._schema_stock_picking_details,
}
@property
- def _schema_picking_details(self):
+ def _schema_stock_picking_details(self):
return {
"picking": {
"type": "dict",
@@ -538,7 +544,9 @@ def _schema_selected_lines(self):
}
def scan_document(self):
- return self._response_schema(next_states={"select_document", "select_line"})
+ return self._response_schema(
+ next_states={"select_document", "select_line", "summary"}
+ )
def list_stock_picking(self):
return self._response_schema(next_states={"manual_selection"})
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 8d05bd4f02..5e58b1ffca 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -53,7 +53,7 @@ def package(self):
"name": {"type": "string", "nullable": False, "required": True},
"weight": {"required": True, "nullable": True, "type": "float"},
"line_count": {"required": True, "nullable": True, "type": "integer"},
- "package_type": {"required": True, "nullable": True, "type": "string"},
+ "package_type_name": {"required": True, "nullable": True, "type": "string"},
}
def lot(self):
diff --git a/shopfloor/tests/test_checkout.py b/shopfloor/tests/test_checkout.py
index 7cd5133cdd..2c6d840bef 100644
--- a/shopfloor/tests/test_checkout.py
+++ b/shopfloor/tests/test_checkout.py
@@ -1,3 +1,5 @@
+from odoo.tests.common import Form
+
from .common import CommonCase
@@ -11,8 +13,7 @@ def setUpClass(cls, *args, **kwargs):
cls.product_b = cls.env["product.product"].create(
{"name": "Product B", "type": "product"}
)
- # TODO
- cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout")
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
@@ -26,3 +27,75 @@ def setUp(self):
def test_to_openapi(self):
# will raise if it fails to generate the openapi specs
self.service.to_openapi()
+
+ def _create_picking(self):
+ picking_form = Form(self.env["stock.picking"])
+ picking_form.picking_type_id = self.picking_type
+ # TODO we must have packages in origin
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = self.product_a
+ move.product_uom_qty = 1
+ picking = picking_form.save()
+ picking.action_confirm()
+ return picking
+
+ def _stock_picking_data(self, picking):
+ return {
+ "id": picking.id,
+ "name": picking.name,
+ "origin": picking.origin,
+ "note": picking.note,
+ "move_lines": [
+ {
+ "id": ml.id,
+ "qty_done": ml.qty_done,
+ "quantity": ml.product_uom_qty,
+ "product": {
+ "id": ml.product_id.id,
+ "name": ml.product_id.name,
+ "display_name": ml.product_id.display_name,
+ "default_code": ml.product_id.default_code,
+ },
+ "lot": {"id": ml.lot_id.id, "name": ml.lot_id.name},
+ "package_src": {
+ "id": ml.package_id.id,
+ "name": ml.package_id.name,
+ "weight": ml.package_id.weight,
+ # TODO
+ "line_count": 0,
+ "package_type_name": ml.package_id.package_type_id.name,
+ },
+ "package_dest": {
+ "id": ml.result_package_id.id,
+ "name": ml.result_package_id.name,
+ "weight": ml.result_package_id.weight,
+ # TODO
+ "line_count": 0,
+ "package_type_name": ml.result_package_id.package_type_id.name,
+ },
+ "location_src": {
+ "id": ml.location_id.id,
+ "name": ml.location_id.name,
+ },
+ "location_dest": {
+ "id": ml.location_dest_id.id,
+ "name": ml.location_dest_id.name,
+ },
+ }
+ for ml in picking.move_line_ids
+ ],
+ }
+
+ def test_scan_document_location_ok(self):
+ picking = self._create_picking()
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+
+ location = picking.location_id
+ # TODO set qty in loc for each move line
+ response = self.service.dispatch(
+ "scan_document", params={"barcode": location.barcode}
+ )
+ self.assert_response(
+ response, next_state="select_line", data=self._stock_picking_data(picking)
+ )
From 5f318b7a432735f73e2865b37891c1a03a4e2e9e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 12 Mar 2020 16:10:53 +0100
Subject: [PATCH 142/940] tests: keep identical quantity for packages
As we reserve a full pack, we can't put more qties
---
shopfloor/tests/common.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index fe4dbeeba4..aa11815b48 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -119,10 +119,11 @@ def _fill_stock_for_moves(cls, moves, in_package=False, in_lot=False):
lot = cls.env["stock.production.lot"].create(
{"product_id": product.id, "company_id": cls.env.company.id}
)
- else:
+ if not (in_lot or in_package):
# always add more quantity in stock to avoid to trigger the
# "zero checks" in tests, not for lots which must have a qty
- # of 1
+ # of 1 and not for packages because we need the strict number
+ # of units to pick a package
qty *= 2
cls._update_qty_in_location(
location, product, qty, package=package, lot=lot
From f225845909caebc72a090b6b012bb6482c82312e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 12 Mar 2020 16:55:35 +0100
Subject: [PATCH 143/940] checkout: first passing test
---
shopfloor/__manifest__.py | 10 ++++
shopfloor/actions/search.py | 3 ++
shopfloor/services/checkout.py | 87 +++++++++++++++++++++++++++++++-
shopfloor/services/schema.py | 15 +++++-
shopfloor/tests/test_checkout.py | 54 ++------------------
5 files changed, 116 insertions(+), 53 deletions(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index ba49f92715..cc03632c6a 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -18,6 +18,16 @@
"auth_api_key",
# https://github.com/OCA/stock-logistics-warehouse/pull/808
"stock_picking_completion_info",
+ # https://github.com/OCA/wms/pull/12
+ "stock_storage_type",
+ # https://github.com/OCA/wms/pull/13
+ "stock_storage_type_putaway_strategy",
+ # https://github.com/OCA/stock-logistics-warehouse/pull/855
+ "stock_location_children",
+ # https://github.com/OCA/stock-logistics-workflow/pull/608
+ "stock_quant_package_dimension",
+ # https://github.com/OCA/stock-logistics-workflow/pull/607
+ "stock_quant_package_product_packaging",
],
"data": [
"security/ir.model.access.csv",
diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py
index 706b6c057e..3878d0b8c5 100644
--- a/shopfloor/actions/search.py
+++ b/shopfloor/actions/search.py
@@ -17,3 +17,6 @@ def location_from_scan(self, barcode):
def package_from_scan(self, barcode):
return self.env["stock.quant.package"].search([("name", "=", barcode)])
+
+ def stock_picking_from_scan(self, barcode):
+ return self.env["stock.picking"].search([("name", "=", barcode)])
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 0e7eba6bb7..ed8b944a64 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -1,3 +1,5 @@
+from odoo import _
+
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -55,7 +57,90 @@ def scan_document(self, barcode):
* summary: stock.picking is selected and all its lines have a
destination pack set
"""
- return self._response()
+ search = self.actions_for("search")
+ picking = search.stock_picking_from_scan(barcode)
+ # TODO find from location, etc
+ # TODO better error message when not available
+ if picking and picking.state == "assigned":
+ return self._response_for_selected_stock_picking(picking)
+ else:
+ return self._response_for_no_stock_picking_found(barcode)
+
+ def _response_for_selected_stock_picking(self, picking):
+ # TODO if all lines have a dest package set, go to summary
+ return self._response(
+ next_state="select_line", data=self._data_for_stock_picking(picking)
+ )
+
+ def _response_for_no_stock_picking_found(self, barcode):
+ return self._response(
+ next_state="select_document",
+ message={
+ "message_type": "error",
+ "message": _("No transfer found for barcode {}").format(barcode),
+ },
+ )
+
+ def _data_for_stock_picking(self, picking):
+ return {
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "origin": picking.origin or "",
+ "note": picking.note or "",
+ "move_lines": [
+ {
+ "id": ml.id,
+ "qty_done": ml.qty_done,
+ "quantity": ml.product_uom_qty,
+ "product": {
+ "id": ml.product_id.id,
+ "name": ml.product_id.name,
+ "display_name": ml.product_id.display_name,
+ "default_code": ml.product_id.default_code or "",
+ },
+ "lot": {"id": ml.lot_id.id, "name": ml.lot_id.name}
+ if ml.lot_id
+ else None,
+ "package_src": {
+ "id": ml.package_id.id,
+ "name": ml.package_id.name,
+ # TODO
+ "weight": 0,
+ # TODO
+ "line_count": 0,
+ "package_type_name": (
+ ml.package_id.package_storage_type_id.name or ""
+ ),
+ }
+ if ml.package_id
+ else None,
+ "package_dest": {
+ "id": ml.result_package_id.id,
+ "name": ml.result_package_id.name,
+ # TODO
+ "weight": 0,
+ # TODO
+ "line_count": 0,
+ "package_type_name": (
+ ml.result_package_id.package_storage_type_id.name or ""
+ ),
+ }
+ if ml.result_package_id
+ else None,
+ "location_src": {
+ "id": ml.location_id.id,
+ "name": ml.location_id.name,
+ },
+ "location_dest": {
+ "id": ml.location_dest_id.id,
+ "name": ml.location_dest_id.name,
+ },
+ }
+ for ml in picking.move_line_ids
+ ],
+ }
+ }
def list_stock_picking(self):
"""List stock.picking records available
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 5e58b1ffca..0bf392d65c 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -20,11 +20,22 @@ def move_line(self):
"qty_done": {"type": "float", "required": True},
"quantity": {"type": "float", "required": True},
"product": {"type": "dict", "required": True, "schema": self.product()},
- "lot": {"type": "dict", "required": True, "schema": self.lot()},
- "package_src": {"type": "dict", "required": True, "schema": self.package()},
+ "lot": {
+ "type": "dict",
+ "required": False,
+ "nullable": True,
+ "schema": self.lot(),
+ },
+ "package_src": {
+ "type": "dict",
+ "required": True,
+ "nullable": True,
+ "schema": self.package(),
+ },
"package_dest": {
"type": "dict",
"required": True,
+ "nullable": True,
"schema": self.package(),
},
"location_src": {
diff --git a/shopfloor/tests/test_checkout.py b/shopfloor/tests/test_checkout.py
index 2c6d840bef..52cb06c529 100644
--- a/shopfloor/tests/test_checkout.py
+++ b/shopfloor/tests/test_checkout.py
@@ -17,6 +17,7 @@ def setUpClass(cls, *args, **kwargs):
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
+ cls.wh.delivery_steps = "pick_pack_ship"
cls.picking_type = cls.process.picking_type_id
def setUp(self):
@@ -40,61 +41,14 @@ def _create_picking(self):
return picking
def _stock_picking_data(self, picking):
- return {
- "id": picking.id,
- "name": picking.name,
- "origin": picking.origin,
- "note": picking.note,
- "move_lines": [
- {
- "id": ml.id,
- "qty_done": ml.qty_done,
- "quantity": ml.product_uom_qty,
- "product": {
- "id": ml.product_id.id,
- "name": ml.product_id.name,
- "display_name": ml.product_id.display_name,
- "default_code": ml.product_id.default_code,
- },
- "lot": {"id": ml.lot_id.id, "name": ml.lot_id.name},
- "package_src": {
- "id": ml.package_id.id,
- "name": ml.package_id.name,
- "weight": ml.package_id.weight,
- # TODO
- "line_count": 0,
- "package_type_name": ml.package_id.package_type_id.name,
- },
- "package_dest": {
- "id": ml.result_package_id.id,
- "name": ml.result_package_id.name,
- "weight": ml.result_package_id.weight,
- # TODO
- "line_count": 0,
- "package_type_name": ml.result_package_id.package_type_id.name,
- },
- "location_src": {
- "id": ml.location_id.id,
- "name": ml.location_id.name,
- },
- "location_dest": {
- "id": ml.location_dest_id.id,
- "name": ml.location_dest_id.name,
- },
- }
- for ml in picking.move_line_ids
- ],
- }
+ return self.service._data_for_stock_picking(picking)
- def test_scan_document_location_ok(self):
+ def test_scan_document_stock_picking_ok(self):
picking = self._create_picking()
self._fill_stock_for_moves(picking.move_lines, in_package=True)
picking.action_assign()
-
- location = picking.location_id
- # TODO set qty in loc for each move line
response = self.service.dispatch(
- "scan_document", params={"barcode": location.barcode}
+ "scan_document", params={"barcode": picking.name}
)
self.assert_response(
response, next_state="select_line", data=self._stock_picking_data(picking)
From ce16640583deea7b719e5fbc041a0910613bbeb4 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 13 Mar 2020 12:19:50 +0100
Subject: [PATCH 144/940] checkout: implement /scan_document
---
shopfloor/actions/message.py | 9 ++
shopfloor/models/stock_location.py | 6 +-
shopfloor/services/checkout.py | 69 ++++++++++--
shopfloor/tests/__init__.py | 3 +-
shopfloor/tests/test_checkout.py | 55 ----------
shopfloor/tests/test_checkout_base.py | 30 ++++++
shopfloor/tests/test_checkout_scan.py | 146 ++++++++++++++++++++++++++
7 files changed, 254 insertions(+), 64 deletions(-)
delete mode 100644 shopfloor/tests/test_checkout.py
create mode 100644 shopfloor/tests/test_checkout_base.py
create mode 100644 shopfloor/tests/test_checkout_scan.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index f338c46658..8c5e519ee2 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -89,6 +89,9 @@ def no_location_found(self):
"message": _("No location found for this barcode."),
}
+ def location_not_allowed(self):
+ return {"message_type": "error", "message": _("Location not allowed here.")}
+
def dest_location_not_allowed(self):
return {"message_type": "error", "message": _("You cannot place it here")}
@@ -166,3 +169,9 @@ def x_units_put_in_package(self, qty, product, package):
qty, product.display_name, package.name
),
}
+
+ def cannot_move_something_in_picking_type(self):
+ return {
+ "message_type": "error",
+ "message": _("You cannot move this using this menu."),
+ }
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
index 6ff8d3a4e8..4e391e629d 100644
--- a/shopfloor/models/stock_location.py
+++ b/shopfloor/models/stock_location.py
@@ -1,9 +1,13 @@
-from odoo import models
+from odoo import fields, models
class StockLocation(models.Model):
_inherit = "stock.location"
+ source_move_line_ids = fields.One2many(
+ comodel_name="stock.move.line", inverse_name="location_id", readonly=True
+ )
+
def is_sublocation_of(self, other):
self.ensure_one()
return bool(
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index ed8b944a64..48447c8639 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -59,12 +59,34 @@ def scan_document(self, barcode):
"""
search = self.actions_for("search")
picking = search.stock_picking_from_scan(barcode)
- # TODO find from location, etc
- # TODO better error message when not available
- if picking and picking.state == "assigned":
+ if not picking:
+ location = search.location_from_scan(barcode)
+ if location:
+ if not location.is_sublocation_of(
+ self.picking_type.default_location_src_id
+ ):
+ return self._response_for_scan_location_not_allowed()
+ lines = location.source_move_line_ids
+ pickings = lines.mapped("picking_id")
+ if len(pickings) == 1:
+ picking = pickings
+ else:
+ return self._response_for_several_stock_picking_found()
+ if not picking:
+ package = search.package_from_scan(barcode)
+ if package:
+ lines = package.planned_move_line_ids
+ pickings = lines.mapped("picking_id")
+ if len(pickings) == 1:
+ picking = pickings
+ if not picking:
+ return self._response_for_no_stock_picking_found()
+ if picking:
+ if picking.picking_type_id != self.picking_type:
+ return self._response_for_scan_picking_type_not_allowed()
+ if picking.state != "assigned":
+ return self._response_for_picking_not_assigned(picking)
return self._response_for_selected_stock_picking(picking)
- else:
- return self._response_for_no_stock_picking_found(barcode)
def _response_for_selected_stock_picking(self, picking):
# TODO if all lines have a dest package set, go to summary
@@ -72,15 +94,48 @@ def _response_for_selected_stock_picking(self, picking):
next_state="select_line", data=self._data_for_stock_picking(picking)
)
- def _response_for_no_stock_picking_found(self, barcode):
+ def _response_for_picking_not_assigned(self, picking):
return self._response(
next_state="select_document",
message={
"message_type": "error",
- "message": _("No transfer found for barcode {}").format(barcode),
+ "message": _("Transfer {} is not entirely available.").format(
+ picking.name
+ ),
},
)
+ def _response_for_several_stock_picking_found(self):
+ return self._response(
+ next_state="select_document",
+ message={
+ "message_type": "error",
+ "message": _(
+ "Several transfers found, please scan a package"
+ " or select a transfer manually."
+ ),
+ },
+ )
+
+ def _response_for_scan_picking_type_not_allowed(self):
+ message = self.actions_for("message")
+ return self._response(
+ next_state="select_document",
+ message=message.cannot_move_something_in_picking_type(),
+ )
+
+ def _response_for_scan_location_not_allowed(self):
+ message = self.actions_for("message")
+ return self._response(
+ next_state="select_document", message=message.location_not_allowed()
+ )
+
+ def _response_for_no_stock_picking_found(self):
+ message = self.actions_for("message")
+ return self._response(
+ next_state="select_document", message=message.barcode_not_found()
+ )
+
def _data_for_stock_picking(self, picking):
return {
"picking": {
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 8e179399c0..598176820c 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -9,4 +9,5 @@
from . import test_cluster_picking_scan
from . import test_cluster_picking_skip
from . import test_cluster_picking_unload
-from . import test_checkout
+from . import test_checkout_base
+from . import test_checkout_scan
diff --git a/shopfloor/tests/test_checkout.py b/shopfloor/tests/test_checkout.py
deleted file mode 100644
index 52cb06c529..0000000000
--- a/shopfloor/tests/test_checkout.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from odoo.tests.common import Form
-
-from .common import CommonCase
-
-
-class CheckoutCase(CommonCase):
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- cls.product_a = cls.env["product.product"].create(
- {"name": "Product A", "type": "product"}
- )
- cls.product_b = cls.env["product.product"].create(
- {"name": "Product B", "type": "product"}
- )
- cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout")
- cls.process = cls.menu.process_id
- cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
- cls.wh = cls.profile.warehouse_id
- cls.wh.delivery_steps = "pick_pack_ship"
- cls.picking_type = cls.process.picking_type_id
-
- def setUp(self):
- super().setUp()
- with self.work_on_services(menu=self.menu, profile=self.profile) as work:
- self.service = work.component(usage="checkout")
-
- def test_to_openapi(self):
- # will raise if it fails to generate the openapi specs
- self.service.to_openapi()
-
- def _create_picking(self):
- picking_form = Form(self.env["stock.picking"])
- picking_form.picking_type_id = self.picking_type
- # TODO we must have packages in origin
- with picking_form.move_ids_without_package.new() as move:
- move.product_id = self.product_a
- move.product_uom_qty = 1
- picking = picking_form.save()
- picking.action_confirm()
- return picking
-
- def _stock_picking_data(self, picking):
- return self.service._data_for_stock_picking(picking)
-
- def test_scan_document_stock_picking_ok(self):
- picking = self._create_picking()
- self._fill_stock_for_moves(picking.move_lines, in_package=True)
- picking.action_assign()
- response = self.service.dispatch(
- "scan_document", params={"barcode": picking.name}
- )
- self.assert_response(
- response, next_state="select_line", data=self._stock_picking_data(picking)
- )
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
new file mode 100644
index 0000000000..540deed801
--- /dev/null
+++ b/shopfloor/tests/test_checkout_base.py
@@ -0,0 +1,30 @@
+from .common import CommonCase
+
+
+class CheckoutCommonCase(CommonCase):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.product_a = cls.env["product.product"].create(
+ {"name": "Product A", "type": "product"}
+ )
+ cls.product_b = cls.env["product.product"].create(
+ {"name": "Product B", "type": "product"}
+ )
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout")
+ cls.process = cls.menu.process_id
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.wh.delivery_steps = "pick_pack_ship"
+ cls.picking_type = cls.process.picking_type_id
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
+ self.service = work.component(usage="checkout")
+
+
+class CheckoutOpenAPICase(CheckoutCommonCase):
+ def test_to_openapi(self):
+ # will raise if it fails to generate the openapi specs
+ self.service.to_openapi()
diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py
new file mode 100644
index 0000000000..f87ff580f8
--- /dev/null
+++ b/shopfloor/tests/test_checkout_scan.py
@@ -0,0 +1,146 @@
+from odoo.tests.common import Form
+
+from .test_checkout_base import CheckoutCommonCase
+
+
+class CheckoutScanCase(CheckoutCommonCase):
+ def _create_picking(self, picking_type=None):
+ picking_form = Form(self.env["stock.picking"])
+ picking_form.picking_type_id = picking_type or self.picking_type
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = self.product_a
+ move.product_uom_qty = 10
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = self.product_b
+ move.product_uom_qty = 10
+ picking = picking_form.save()
+ picking.action_confirm()
+ return picking
+
+ def _stock_picking_data(self, picking):
+ return self.service._data_for_stock_picking(picking)
+
+ def _test_scan_ok(self, barcode_func):
+ picking = self._create_picking()
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ barcode = barcode_func(picking)
+ response = self.service.dispatch("scan_document", params={"barcode": barcode})
+ self.assert_response(
+ response, next_state="select_line", data=self._stock_picking_data(picking)
+ )
+
+ def test_scan_document_stock_picking_ok(self):
+ self._test_scan_ok(lambda picking: picking.name)
+
+ def test_scan_document_location_ok(self):
+ self._test_scan_ok(lambda picking: picking.move_line_ids.location_id.barcode)
+
+ def test_scan_document_package_ok(self):
+ self._test_scan_ok(lambda picking: picking.move_line_ids.package_id.name)
+
+ def test_scan_document_error_not_found(self):
+ response = self.service.dispatch("scan_document", params={"barcode": "NOPE"})
+ self.assert_response(
+ response,
+ next_state="select_document",
+ message={"message_type": "error", "message": "Barcode not found"},
+ )
+
+ def _test_scan_document_error_not_available(self, barcode_func):
+ picking = self._create_picking()
+ # in this test, we want the picking not to be available, but
+ # if we leave the shipping policy to direct, a single move assigned
+ # would make the picking available
+ picking.move_type = "one"
+ # the picking will have one line available, so the endpoint can find
+ # something from a location or package but should reject the picking as
+ # it is not entirely available
+ self._fill_stock_for_moves(picking.move_lines[0], in_package=True)
+ picking.action_assign()
+ barcode = barcode_func(picking)
+ response = self.service.dispatch("scan_document", params={"barcode": barcode})
+ self.assert_response(
+ response,
+ next_state="select_document",
+ message={
+ "message_type": "error",
+ "message": "Transfer {} is not entirely available.".format(
+ picking.name
+ ),
+ },
+ )
+
+ def test_scan_document_error_not_available_picking(self):
+ self._test_scan_document_error_not_available(lambda picking: picking.name)
+
+ def test_scan_document_error_not_available_location(self):
+ self._test_scan_document_error_not_available(
+ lambda picking: picking.move_line_ids.location_id.barcode
+ )
+
+ def test_scan_document_error_not_available_package(self):
+ self._test_scan_document_error_not_available(
+ lambda picking: picking.move_line_ids.package_id.name
+ )
+
+ def test_scan_document_error_location_not_child_of_type(self):
+ picking = self._create_picking()
+ picking.location_id = self.dispatch_location
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ response = self.service.dispatch(
+ "scan_document", params={"barcode": picking.location_id.barcode}
+ )
+ self.assert_response(
+ response,
+ next_state="select_document",
+ message={"message_type": "error", "message": "Location not allowed here."},
+ )
+
+ def _test_scan_document_error_different_picking_type(self, barcode_func):
+ picking = self._create_picking(picking_type=self.wh.pick_type_id)
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ barcode = barcode_func(picking)
+ response = self.service.dispatch("scan_document", params={"barcode": barcode})
+ self.assert_response(
+ response,
+ next_state="select_document",
+ message={
+ "message_type": "error",
+ "message": "You cannot move this using this menu.",
+ },
+ )
+
+ def test_scan_document_error_different_picking_type_picking(self):
+ self._test_scan_document_error_different_picking_type(
+ lambda picking: picking.name
+ )
+
+ def test_scan_document_error_different_picking_type_package(self):
+ self._test_scan_document_error_different_picking_type(
+ lambda picking: picking.move_line_ids.package_id.name
+ )
+
+ def test_scan_document_error_location_several_pickings(self):
+ picking = self._create_picking()
+ # create a second picking at the same place so we don't
+ # know which picking to use
+ picking2 = self._create_picking()
+ pickings = picking | picking2
+ self._fill_stock_for_moves(pickings.move_lines, in_package=True)
+ pickings.action_assign()
+ response = self.service.dispatch(
+ "scan_document",
+ params={"barcode": picking.move_line_ids.location_id.barcode},
+ )
+ self.assert_response(
+ response,
+ next_state="select_document",
+ message={
+ "message_type": "error",
+ "message": "Several transfers found, please scan a package"
+ " or select a transfer manually.",
+ },
+ )
From b9c079661c920510f7fc874cddddd83d0f15b196 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 13 Mar 2020 14:08:01 +0100
Subject: [PATCH 145/940] checkout: implement /list_stock_picking
---
shopfloor/services/checkout.py | 68 ++++++++++++++++++++++++-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_checkout_base.py | 19 +++++++
shopfloor/tests/test_checkout_scan.py | 18 -------
shopfloor/tests/test_checkout_select.py | 37 ++++++++++++++
5 files changed, 123 insertions(+), 20 deletions(-)
create mode 100644 shopfloor/tests/test_checkout_select.py
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 48447c8639..b6f1bd5b78 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -143,6 +143,7 @@ def _data_for_stock_picking(self, picking):
"name": picking.name,
"origin": picking.origin or "",
"note": picking.note or "",
+ # TODO add partner
"move_lines": [
{
"id": ml.id,
@@ -197,6 +198,15 @@ def _data_for_stock_picking(self, picking):
}
}
+ def _domain_for_list_stock_picking(self):
+ return [
+ ("state", "=", "assigned"),
+ ("picking_type_id", "=", self.picking_type.id),
+ ]
+
+ def _order_for_list_stock_picking(self):
+ return "scheduled_date desc, id asc"
+
def list_stock_picking(self):
"""List stock.picking records available
@@ -206,7 +216,26 @@ def list_stock_picking(self):
Transitions:
* manual_selection: to the selection screen
"""
- return self._response()
+ pickings = self.env["stock.picking"].search(
+ self._domain_for_list_stock_picking(),
+ order=self._order_for_list_stock_picking(),
+ )
+ data = {
+ "pickings": [self._data_picking_for_list(picking) for picking in pickings]
+ }
+ return self._response(next_state="manual_selection", data=data)
+
+ def _data_picking_for_list(self, picking):
+ return {
+ "id": picking.id,
+ "name": picking.name,
+ "origin": picking.origin or "",
+ "note": picking.note or "",
+ "line_count": len(picking.move_line_ids),
+ "partner": {"id": picking.partner_id.id, "name": picking.partner_id.name}
+ if picking.partner_id
+ else None,
+ }
def select(self, picking_id):
"""Select a stock picking for the process
@@ -590,7 +619,7 @@ def _states(self):
"""
return {
"select_document": {},
- "manual_selection": {},
+ "manual_selection": self._schema_selection_list,
"select_line": self._schema_stock_picking_details,
"select_package": self._schema_selected_lines,
"change_quantity": self._schema_selected_lines,
@@ -621,6 +650,41 @@ def _schema_stock_picking_details(self):
}
}
+ @property
+ def _schema_selection_list(self):
+ return {
+ "pickings": {
+ "type": "list",
+ "schema": {
+ "type": "dict",
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "origin": {
+ "type": "string",
+ "nullable": True,
+ "required": True,
+ },
+ "note": {"type": "string", "nullable": True, "required": True},
+ "line_count": {"type": "integer", "required": True},
+ "partner": {
+ "type": "dict",
+ "nullable": True,
+ "required": True,
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ },
+ },
+ },
+ },
+ }
+ }
+
@property
def _schema_select_package(self):
return {
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 598176820c..693366f049 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -11,3 +11,4 @@
from . import test_cluster_picking_unload
from . import test_checkout_base
from . import test_checkout_scan
+from . import test_checkout_select
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index 540deed801..1c5848ce4a 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -1,3 +1,5 @@
+from odoo.tests.common import Form
+
from .common import CommonCase
@@ -23,6 +25,23 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="checkout")
+ def _create_picking(self, picking_type=None):
+ picking_form = Form(self.env["stock.picking"])
+ picking_form.picking_type_id = picking_type or self.picking_type
+ picking_form.partner_id = self.customer
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = self.product_a
+ move.product_uom_qty = 10
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = self.product_b
+ move.product_uom_qty = 10
+ picking = picking_form.save()
+ picking.action_confirm()
+ return picking
+
+ def _stock_picking_data(self, picking):
+ return self.service._data_for_stock_picking(picking)
+
class CheckoutOpenAPICase(CheckoutCommonCase):
def test_to_openapi(self):
diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py
index f87ff580f8..98a1e6d2f4 100644
--- a/shopfloor/tests/test_checkout_scan.py
+++ b/shopfloor/tests/test_checkout_scan.py
@@ -1,25 +1,7 @@
-from odoo.tests.common import Form
-
from .test_checkout_base import CheckoutCommonCase
class CheckoutScanCase(CheckoutCommonCase):
- def _create_picking(self, picking_type=None):
- picking_form = Form(self.env["stock.picking"])
- picking_form.picking_type_id = picking_type or self.picking_type
- with picking_form.move_ids_without_package.new() as move:
- move.product_id = self.product_a
- move.product_uom_qty = 10
- with picking_form.move_ids_without_package.new() as move:
- move.product_id = self.product_b
- move.product_uom_qty = 10
- picking = picking_form.save()
- picking.action_confirm()
- return picking
-
- def _stock_picking_data(self, picking):
- return self.service._data_for_stock_picking(picking)
-
def _test_scan_ok(self, barcode_func):
picking = self._create_picking()
self._fill_stock_for_moves(picking.move_lines, in_package=True)
diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py
new file mode 100644
index 0000000000..21fab504a8
--- /dev/null
+++ b/shopfloor/tests/test_checkout_select.py
@@ -0,0 +1,37 @@
+from .test_checkout_base import CheckoutCommonCase
+
+
+class CheckoutLisStockPickingCase(CheckoutCommonCase):
+ def test_list_stock_picking(self):
+ picking1 = self._create_picking()
+ picking2 = self._create_picking()
+ # should not be in the list because another type:
+ picking3 = self._create_picking(picking_type=self.wh.pick_type_id)
+ # should not be in list because not assigned:
+ self._create_picking()
+ to_assign = picking1 | picking2 | picking3
+ self._fill_stock_for_moves(to_assign.move_lines, in_package=True)
+ to_assign.action_assign()
+ response = self.service.dispatch("list_stock_picking", params={})
+ expected = {
+ "pickings": [
+ {
+ "id": picking2.id,
+ "line_count": len(picking2.move_line_ids),
+ "name": picking2.name,
+ "note": "",
+ "origin": "",
+ "partner": {"id": self.customer.id, "name": self.customer.name},
+ },
+ {
+ "id": picking1.id,
+ "line_count": len(picking1.move_line_ids),
+ "name": picking1.name,
+ "note": "",
+ "origin": "",
+ "partner": {"id": self.customer.id, "name": self.customer.name},
+ },
+ ]
+ }
+
+ self.assert_response(response, next_state="manual_selection", data=expected)
From a8740bc07948bf297ab0d7c4140e6b1c89a2a160 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 13 Mar 2020 16:04:13 +0100
Subject: [PATCH 146/940] fixup! checkout: implement /list_stock_picking
---
shopfloor/services/checkout.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index b6f1bd5b78..e53768970e 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -205,7 +205,7 @@ def _domain_for_list_stock_picking(self):
]
def _order_for_list_stock_picking(self):
- return "scheduled_date desc, id asc"
+ return "scheduled_date asc, id asc"
def list_stock_picking(self):
"""List stock.picking records available
From fd2ae98ee92919a0b3908233bd8f0e2e06651721 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 13 Mar 2020 16:04:59 +0100
Subject: [PATCH 147/940] checkout: implement /select
---
shopfloor/services/checkout.py | 33 ++++++++++++++-----------
shopfloor/tests/test_checkout_select.py | 25 ++++++++++++++-----
2 files changed, 37 insertions(+), 21 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index e53768970e..22aa97c3c3 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -79,24 +79,26 @@ def scan_document(self, barcode):
pickings = lines.mapped("picking_id")
if len(pickings) == 1:
picking = pickings
+ return self._select_picking(picking, "select_document")
+
+ def _select_picking(self, picking, state_for_error):
if not picking:
- return self._response_for_no_stock_picking_found()
- if picking:
- if picking.picking_type_id != self.picking_type:
- return self._response_for_scan_picking_type_not_allowed()
- if picking.state != "assigned":
- return self._response_for_picking_not_assigned(picking)
- return self._response_for_selected_stock_picking(picking)
+ return self._response_for_no_stock_picking_found(state_for_error)
+ if picking.picking_type_id != self.picking_type:
+ return self._response_for_scan_picking_type_not_allowed(state_for_error)
+ if picking.state != "assigned":
+ return self._response_for_picking_not_assigned(picking, state_for_error)
+ # TODO if all lines have a dest package set, go to summary
+ return self._response_for_selected_stock_picking(picking)
def _response_for_selected_stock_picking(self, picking):
- # TODO if all lines have a dest package set, go to summary
return self._response(
next_state="select_line", data=self._data_for_stock_picking(picking)
)
- def _response_for_picking_not_assigned(self, picking):
+ def _response_for_picking_not_assigned(self, picking, next_state):
return self._response(
- next_state="select_document",
+ next_state=next_state,
message={
"message_type": "error",
"message": _("Transfer {} is not entirely available.").format(
@@ -117,10 +119,10 @@ def _response_for_several_stock_picking_found(self):
},
)
- def _response_for_scan_picking_type_not_allowed(self):
+ def _response_for_scan_picking_type_not_allowed(self, next_state):
message = self.actions_for("message")
return self._response(
- next_state="select_document",
+ next_state=next_state,
message=message.cannot_move_something_in_picking_type(),
)
@@ -130,10 +132,10 @@ def _response_for_scan_location_not_allowed(self):
next_state="select_document", message=message.location_not_allowed()
)
- def _response_for_no_stock_picking_found(self):
+ def _response_for_no_stock_picking_found(self, next_state):
message = self.actions_for("message")
return self._response(
- next_state="select_document", message=message.barcode_not_found()
+ next_state=next_state, message=message.barcode_not_found()
)
def _data_for_stock_picking(self, picking):
@@ -255,7 +257,8 @@ def select(self, picking_id):
* select_line: the "normal" case, when the user has to put in pack/move
lines
"""
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id)
+ return self._select_picking(picking, "manual_selection")
def scan_line(self, picking_id, barcode):
"""Scan move lines of the stock picking
diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py
index 21fab504a8..d860e45c3b 100644
--- a/shopfloor/tests/test_checkout_select.py
+++ b/shopfloor/tests/test_checkout_select.py
@@ -16,17 +16,17 @@ def test_list_stock_picking(self):
expected = {
"pickings": [
{
- "id": picking2.id,
- "line_count": len(picking2.move_line_ids),
- "name": picking2.name,
+ "id": picking1.id,
+ "line_count": len(picking1.move_line_ids),
+ "name": picking1.name,
"note": "",
"origin": "",
"partner": {"id": self.customer.id, "name": self.customer.name},
},
{
- "id": picking1.id,
- "line_count": len(picking1.move_line_ids),
- "name": picking1.name,
+ "id": picking2.id,
+ "line_count": len(picking2.move_line_ids),
+ "name": picking2.name,
"note": "",
"origin": "",
"partner": {"id": self.customer.id, "name": self.customer.name},
@@ -35,3 +35,16 @@ def test_list_stock_picking(self):
}
self.assert_response(response, next_state="manual_selection", data=expected)
+
+
+class CheckoutSelectCase(CheckoutCommonCase):
+ def test_select_ok(self):
+ picking = self._create_picking()
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ response = self.service.dispatch("select", params={"picking_id": picking.id})
+ self.assert_response(
+ response, next_state="select_line", data=self._stock_picking_data(picking)
+ )
+
+ # TODO error cases and go to summary
From c0fd09084502aa5b55f419cd893e8700e1fa3c3d Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 16 Mar 2020 11:45:37 +0100
Subject: [PATCH 148/940] checkout: handle exceptions
---
shopfloor/actions/message.py | 6 +++
shopfloor/services/checkout.py | 46 +++++++++++++--------
shopfloor/tests/test_checkout_base.py | 13 +++---
shopfloor/tests/test_checkout_scan.py | 4 +-
shopfloor/tests/test_checkout_select.py | 53 ++++++++++++++++++++++---
5 files changed, 91 insertions(+), 31 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 8c5e519ee2..1e9f416b92 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -175,3 +175,9 @@ def cannot_move_something_in_picking_type(self):
"message_type": "error",
"message": _("You cannot move this using this menu."),
}
+
+ def stock_picking_not_available(self, picking):
+ return {
+ "message_type": "error",
+ "message": _("Transfer {} is not available.").format(picking.name),
+ }
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 22aa97c3c3..94ba08b2a4 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -82,12 +82,25 @@ def scan_document(self, barcode):
return self._select_picking(picking, "select_document")
def _select_picking(self, picking, state_for_error):
+ message = self.actions_for("message")
if not picking:
- return self._response_for_no_stock_picking_found(state_for_error)
+ if state_for_error == "manual_selection":
+ return self._response_for_manual_selection(
+ message=message.operation_not_found()
+ )
+ return self._response_for_barcode_no_stock_picking_found()
if picking.picking_type_id != self.picking_type:
- return self._response_for_scan_picking_type_not_allowed(state_for_error)
+ if state_for_error == "manual_selection":
+ return self._response_for_manual_selection(
+ message=message.cannot_move_something_in_picking_type()
+ )
+ return self._response_for_scan_picking_type_not_allowed()
if picking.state != "assigned":
- return self._response_for_picking_not_assigned(picking, state_for_error)
+ if state_for_error == "manual_selection":
+ return self._response_for_manual_selection(
+ message=message.stock_picking_not_available(picking)
+ )
+ return self._response_for_picking_not_assigned(picking)
# TODO if all lines have a dest package set, go to summary
return self._response_for_selected_stock_picking(picking)
@@ -96,15 +109,11 @@ def _response_for_selected_stock_picking(self, picking):
next_state="select_line", data=self._data_for_stock_picking(picking)
)
- def _response_for_picking_not_assigned(self, picking, next_state):
+ def _response_for_picking_not_assigned(self, picking):
+ message = self.actions_for("message")
return self._response(
- next_state=next_state,
- message={
- "message_type": "error",
- "message": _("Transfer {} is not entirely available.").format(
- picking.name
- ),
- },
+ next_state="select_document",
+ message=message.stock_picking_not_available(picking),
)
def _response_for_several_stock_picking_found(self):
@@ -119,10 +128,10 @@ def _response_for_several_stock_picking_found(self):
},
)
- def _response_for_scan_picking_type_not_allowed(self, next_state):
+ def _response_for_scan_picking_type_not_allowed(self):
message = self.actions_for("message")
return self._response(
- next_state=next_state,
+ next_state="select_document",
message=message.cannot_move_something_in_picking_type(),
)
@@ -132,10 +141,10 @@ def _response_for_scan_location_not_allowed(self):
next_state="select_document", message=message.location_not_allowed()
)
- def _response_for_no_stock_picking_found(self, next_state):
+ def _response_for_barcode_no_stock_picking_found(self):
message = self.actions_for("message")
return self._response(
- next_state=next_state, message=message.barcode_not_found()
+ next_state="select_document", message=message.barcode_not_found()
)
def _data_for_stock_picking(self, picking):
@@ -218,6 +227,9 @@ def list_stock_picking(self):
Transitions:
* manual_selection: to the selection screen
"""
+ return self._response_for_manual_selection()
+
+ def _response_for_manual_selection(self, message=None):
pickings = self.env["stock.picking"].search(
self._domain_for_list_stock_picking(),
order=self._order_for_list_stock_picking(),
@@ -225,7 +237,7 @@ def list_stock_picking(self):
data = {
"pickings": [self._data_picking_for_list(picking) for picking in pickings]
}
- return self._response(next_state="manual_selection", data=data)
+ return self._response(next_state="manual_selection", data=data, message=message)
def _data_picking_for_list(self, picking):
return {
@@ -257,7 +269,7 @@ def select(self, picking_id):
* select_line: the "normal" case, when the user has to put in pack/move
lines
"""
- picking = self.env["stock.picking"].browse(picking_id)
+ picking = self.env["stock.picking"].browse(picking_id).exists()
return self._select_picking(picking, "manual_selection")
def scan_line(self, picking_id, barcode):
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index 1c5848ce4a..77accd1a2d 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -25,15 +25,16 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="checkout")
- def _create_picking(self, picking_type=None):
- picking_form = Form(self.env["stock.picking"])
- picking_form.picking_type_id = picking_type or self.picking_type
- picking_form.partner_id = self.customer
+ @classmethod
+ def _create_picking(cls, picking_type=None):
+ picking_form = Form(cls.env["stock.picking"])
+ picking_form.picking_type_id = picking_type or cls.picking_type
+ picking_form.partner_id = cls.customer
with picking_form.move_ids_without_package.new() as move:
- move.product_id = self.product_a
+ move.product_id = cls.product_a
move.product_uom_qty = 10
with picking_form.move_ids_without_package.new() as move:
- move.product_id = self.product_b
+ move.product_id = cls.product_b
move.product_uom_qty = 10
picking = picking_form.save()
picking.action_confirm()
diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py
index 98a1e6d2f4..017ddf57e1 100644
--- a/shopfloor/tests/test_checkout_scan.py
+++ b/shopfloor/tests/test_checkout_scan.py
@@ -47,9 +47,7 @@ def _test_scan_document_error_not_available(self, barcode_func):
next_state="select_document",
message={
"message_type": "error",
- "message": "Transfer {} is not entirely available.".format(
- picking.name
- ),
+ "message": "Transfer {} is not available.".format(picking.name),
},
)
diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py
index d860e45c3b..bc37d3fc2c 100644
--- a/shopfloor/tests/test_checkout_select.py
+++ b/shopfloor/tests/test_checkout_select.py
@@ -38,13 +38,56 @@ def test_list_stock_picking(self):
class CheckoutSelectCase(CheckoutCommonCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.picking = cls._create_picking()
+ cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True)
+ cls.picking.action_assign()
+
def test_select_ok(self):
- picking = self._create_picking()
- self._fill_stock_for_moves(picking.move_lines, in_package=True)
- picking.action_assign()
+ response = self.service.dispatch(
+ "select", params={"picking_id": self.picking.id}
+ )
+ self.assert_response(
+ response,
+ next_state="select_line",
+ data=self._stock_picking_data(self.picking),
+ )
+
+ def _test_error(self, picking, msg):
response = self.service.dispatch("select", params={"picking_id": picking.id})
self.assert_response(
- response, next_state="select_line", data=self._stock_picking_data(picking)
+ response,
+ next_state="manual_selection",
+ message={"message_type": "error", "message": msg},
+ data={
+ "pickings": [
+ {
+ "id": self.picking.id,
+ "line_count": len(self.picking.move_line_ids),
+ "name": self.picking.name,
+ "note": "",
+ "origin": "",
+ "partner": {"id": self.customer.id, "name": self.customer.name},
+ }
+ ]
+ },
)
- # TODO error cases and go to summary
+ def test_select_error_not_found(self):
+ picking = self._create_picking()
+ picking.unlink()
+ self._test_error(picking, "This operation does not exist anymore.")
+
+ def test_select_error_not_available(self):
+ picking = self._create_picking()
+ self._test_error(picking, "Transfer {} is not available.".format(picking.name))
+
+ def test_select_error_not_allowed(self):
+ picking = self._create_picking(picking_type=self.wh.pick_type_id)
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ self._test_error(
+ picking, "You cannot move this using this menu.".format(picking.name)
+ )
From a37af227c805bb1029c0f93c2288f8116b88b398 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 16 Mar 2020 15:33:14 +0100
Subject: [PATCH 149/940] checkout: implement /scan_line happy path
---
shopfloor/actions/message.py | 6 +
shopfloor/actions/search.py | 12 +
shopfloor/services/checkout.py | 264 ++++++++++-----------
shopfloor/services/schema.py | 18 ++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_app.py | 41 ++--
shopfloor/tests/test_checkout_base.py | 27 ++-
shopfloor/tests/test_checkout_scan.py | 4 +-
shopfloor/tests/test_checkout_scan_line.py | 109 +++++++++
shopfloor/tests/test_checkout_select.py | 6 +-
shopfloor/tests/test_menu.py | 25 +-
11 files changed, 327 insertions(+), 186 deletions(-)
create mode 100644 shopfloor/tests/test_checkout_scan_line.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 1e9f416b92..63434a6b5d 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -68,6 +68,12 @@ def operation_not_found(self):
"message": _("This operation does not exist anymore."),
}
+ def stock_picking_not_found(self):
+ return {
+ "message_type": "error",
+ "message": _("This transfer does not exist anymore."),
+ }
+
def record_not_found(self):
return {
"message_type": "error",
diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py
index 3878d0b8c5..c170963b1d 100644
--- a/shopfloor/actions/search.py
+++ b/shopfloor/actions/search.py
@@ -20,3 +20,15 @@ def package_from_scan(self, barcode):
def stock_picking_from_scan(self, barcode):
return self.env["stock.picking"].search([("name", "=", barcode)])
+
+ def product_from_scan(self, barcode):
+ product = self.env["product.product"].search([("barcode", "=", barcode)])
+ if not product:
+ packaging = self.env["product.packaging"].search(
+ [("product_id", "!=", False), ("barcode", "=", barcode)]
+ )
+ product = packaging.product_id
+ return product
+
+ def lot_from_scan(self, barcode):
+ return self.env["stock.production.lot"].search([("name", "=", barcode)])
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 94ba08b2a4..76957097e8 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -86,7 +86,7 @@ def _select_picking(self, picking, state_for_error):
if not picking:
if state_for_error == "manual_selection":
return self._response_for_manual_selection(
- message=message.operation_not_found()
+ message=message.stock_picking_not_found()
)
return self._response_for_barcode_no_stock_picking_found()
if picking.picking_type_id != self.picking_type:
@@ -106,7 +106,8 @@ def _select_picking(self, picking, state_for_error):
def _response_for_selected_stock_picking(self, picking):
return self._response(
- next_state="select_line", data=self._data_for_stock_picking(picking)
+ next_state="select_line",
+ data={"picking": self._data_for_stock_picking(picking)},
)
def _response_for_picking_not_assigned(self, picking):
@@ -147,68 +148,73 @@ def _response_for_barcode_no_stock_picking_found(self):
next_state="select_document", message=message.barcode_not_found()
)
- def _data_for_stock_picking(self, picking):
+ def _response_for_stock_picking_has_been_deleted(self):
+ message = self.actions_for("message")
+ return self._response(
+ next_state="select_document", message=message.stock_picking_not_found()
+ )
+
+ def _data_for_move_line(self, move_line):
return {
- "picking": {
- "id": picking.id,
- "name": picking.name,
- "origin": picking.origin or "",
- "note": picking.note or "",
- # TODO add partner
- "move_lines": [
- {
- "id": ml.id,
- "qty_done": ml.qty_done,
- "quantity": ml.product_uom_qty,
- "product": {
- "id": ml.product_id.id,
- "name": ml.product_id.name,
- "display_name": ml.product_id.display_name,
- "default_code": ml.product_id.default_code or "",
- },
- "lot": {"id": ml.lot_id.id, "name": ml.lot_id.name}
- if ml.lot_id
- else None,
- "package_src": {
- "id": ml.package_id.id,
- "name": ml.package_id.name,
- # TODO
- "weight": 0,
- # TODO
- "line_count": 0,
- "package_type_name": (
- ml.package_id.package_storage_type_id.name or ""
- ),
- }
- if ml.package_id
- else None,
- "package_dest": {
- "id": ml.result_package_id.id,
- "name": ml.result_package_id.name,
- # TODO
- "weight": 0,
- # TODO
- "line_count": 0,
- "package_type_name": (
- ml.result_package_id.package_storage_type_id.name or ""
- ),
- }
- if ml.result_package_id
- else None,
- "location_src": {
- "id": ml.location_id.id,
- "name": ml.location_id.name,
- },
- "location_dest": {
- "id": ml.location_dest_id.id,
- "name": ml.location_dest_id.name,
- },
- }
- for ml in picking.move_line_ids
- ],
+ "id": move_line.id,
+ "qty_done": move_line.qty_done,
+ "quantity": move_line.product_uom_qty,
+ "product": {
+ "id": move_line.product_id.id,
+ "name": move_line.product_id.name,
+ "display_name": move_line.product_id.display_name,
+ "default_code": move_line.product_id.default_code or "",
+ },
+ "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name}
+ if move_line.lot_id
+ else None,
+ "package_src": {
+ "id": move_line.package_id.id,
+ "name": move_line.package_id.name,
+ # TODO
+ "weight": 0,
+ # TODO
+ "line_count": 0,
+ "package_type_name": (
+ move_line.package_id.package_storage_type_id.name or ""
+ ),
}
+ if move_line.package_id
+ else None,
+ "package_dest": {
+ "id": move_line.result_package_id.id,
+ "name": move_line.result_package_id.name,
+ # TODO
+ "weight": 0,
+ # TODO
+ "line_count": 0,
+ "package_type_name": (
+ move_line.result_package_id.package_storage_type_id.name or ""
+ ),
+ }
+ if move_line.result_package_id
+ else None,
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "location_dest": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
}
+ def _data_for_stock_picking(self, picking):
+ data = self._data_picking_base(picking)
+ data.update(
+ {
+ "move_lines": [
+ self._data_for_move_line(ml) for ml in picking.move_line_ids
+ ]
+ }
+ )
+ return data
+
def _domain_for_list_stock_picking(self):
return [
("state", "=", "assigned"),
@@ -234,12 +240,10 @@ def _response_for_manual_selection(self, message=None):
self._domain_for_list_stock_picking(),
order=self._order_for_list_stock_picking(),
)
- data = {
- "pickings": [self._data_picking_for_list(picking) for picking in pickings]
- }
+ data = {"pickings": [self._data_picking_base(picking) for picking in pickings]}
return self._response(next_state="manual_selection", data=data, message=message)
- def _data_picking_for_list(self, picking):
+ def _data_picking_base(self, picking):
return {
"id": picking.id,
"name": picking.name,
@@ -272,6 +276,23 @@ def select(self, picking_id):
picking = self.env["stock.picking"].browse(picking_id).exists()
return self._select_picking(picking, "manual_selection")
+ def _response_for_select_package(self, lines):
+ picking = lines.mapped("picking_id")
+ return self._response(
+ next_state="select_package",
+ data={
+ "selected_move_lines": [
+ self._data_for_move_line(line) for line in lines
+ ],
+ "picking": self._data_picking_base(picking),
+ },
+ )
+
+ def _select_scanned_lines(self, lines):
+ for line in lines:
+ line.qty_done = line.product_uom_qty
+ return self._response_for_select_package(lines)
+
def scan_line(self, picking_id, barcode):
"""Scan move lines of the stock picking
@@ -291,6 +312,37 @@ def scan_line(self, picking_id, barcode):
* select_package: lines are selected, user is redirected to this
screen to change the qty done and destination pack if needed
"""
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+
+ search = self.actions_for("search")
+
+ package = search.package_from_scan(barcode)
+ if package:
+ lines = picking.move_line_ids.filtered(lambda l: l.package_id == package)
+ if not lines:
+ # TODO error package not in picking
+ pass
+ return self._select_scanned_lines(lines)
+
+ product = search.product_from_scan(barcode)
+ if product:
+ # TODO product tracked by lot: must scan lot
+ lines = picking.move_line_ids.filtered(lambda l: l.product_id == product)
+ # TODO: if no lines: error
+ if len(lines.mapped("package_id")) > 1:
+ # TODO must scan package
+ pass
+ return self._select_scanned_lines(lines)
+
+ lot = search.lot_from_scan(barcode)
+ if lot:
+ lines = picking.move_line_ids.filtered(lambda l: l.lot_id == lot)
+ # TODO: if no lines: error
+ return self._select_scanned_lines(lines)
+
+ # TODO barcode not found
return self._response()
def select_line(self, picking_id, package_id=None, move_line_id=None):
@@ -646,57 +698,23 @@ def _states(self):
@property
def _schema_stock_picking_details(self):
- return {
- "picking": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "origin": {"type": "string", "nullable": True, "required": True},
- "note": {"type": "string", "nullable": True, "required": True},
- "move_lines": {
- "type": "list",
- "schema": {
- "type": "dict",
- "schema": self.schemas().move_line(),
- },
- },
- },
+ schema = self.schemas().picking()
+ schema.update(
+ {
+ "move_lines": {
+ "type": "list",
+ "schema": {"type": "dict", "schema": self.schemas().move_line()},
+ }
}
- }
+ )
+ return {"picking": {"type": "dict", "schema": schema}}
@property
def _schema_selection_list(self):
return {
"pickings": {
"type": "list",
- "schema": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "origin": {
- "type": "string",
- "nullable": True,
- "required": True,
- },
- "note": {"type": "string", "nullable": True, "required": True},
- "line_count": {"type": "integer", "required": True},
- "partner": {
- "type": "dict",
- "nullable": True,
- "required": True,
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
- },
- },
- },
+ "schema": {"type": "dict", "schema": self.schemas().picking()},
}
}
@@ -711,15 +729,7 @@ def _schema_select_package(self):
"type": "list",
"schema": {"type": "dict", "schema": self.schemas().package()},
},
- "picking": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "origin": {"type": "string", "nullable": True, "required": True},
- "note": {"type": "string", "nullable": True, "required": True},
- },
- },
+ "picking": {"type": "dict", "schema": self.schemas().picking()},
}
@property
@@ -733,15 +743,7 @@ def _schema_select_package_type(self):
"type": "list",
"schema": {"type": "dict", "schema": self.schemas().package_type()},
},
- "picking": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "origin": {"type": "string", "nullable": True, "required": True},
- "note": {"type": "string", "nullable": True, "required": True},
- },
- },
+ "picking": {"type": "dict", "schema": self.schemas().picking()},
}
@property
@@ -751,15 +753,7 @@ def _schema_selected_lines(self):
"type": "list",
"schema": {"type": "dict", "schema": self.schemas().move_line()},
},
- "picking": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "origin": {"type": "string", "nullable": True, "required": True},
- "note": {"type": "string", "nullable": True, "required": True},
- },
- },
+ "picking": {"type": "dict", "schema": self.schemas().picking()},
}
def scan_document(self):
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 0bf392d65c..63bede3dad 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -14,6 +14,24 @@ class BaseShopfloorSchemaResponse(Component):
_usage = "schema"
_is_rest_service_component = False
+ def picking(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "origin": {"type": "string", "nullable": True, "required": True},
+ "note": {"type": "string", "nullable": True, "required": True},
+ "line_count": {"type": "integer", "nullable": True, "required": True},
+ "partner": {
+ "type": "dict",
+ "nullable": True,
+ "required": True,
+ "schema": {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ },
+ },
+ }
+
def move_line(self):
return {
"id": {"type": "integer", "required": True},
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 693366f049..e002c1d6cb 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -12,3 +12,4 @@
from . import test_checkout_base
from . import test_checkout_scan
from . import test_checkout_select
+from . import test_checkout_scan_line
diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py
index e431ba588b..ff29c176a3 100644
--- a/shopfloor/tests/test_app.py
+++ b/shopfloor/tests/test_app.py
@@ -15,37 +15,32 @@ def test_user_config(self):
"""Request /app/user_config"""
# Simulate the client asking the configuration
response = self.service.dispatch("user_config")
+ menus = self.env["shopfloor.menu"].search([])
+ profiles = self.env["shopfloor.profile"].search([])
self.assert_response(
response,
data={
"menus": [
{
- "id": self.ANY,
- "name": "Put-Away Reach Truck",
- "process": {"id": self.ANY, "code": "single_pack_putaway"},
- },
- {
- "id": self.ANY,
- "name": "Single Pallet Transfer",
- "process": {"id": self.ANY, "code": "single_pack_transfer"},
- },
- {
- "id": self.ANY,
- "name": "Cluster Picking",
- "process": {"id": self.ANY, "code": "cluster_picking"},
- },
+ "id": menu.id,
+ "name": menu.name,
+ "process": {
+ "id": menu.process_id.id,
+ "code": menu.process_id.code,
+ },
+ }
+ for menu in menus
],
"profiles": [
{
- "id": self.ANY,
- "name": "Highbay Truck",
- "warehouse": {"id": self.ANY, "name": "YourCompany"},
- },
- {
- "id": self.ANY,
- "name": "Shelf 1",
- "warehouse": {"id": self.ANY, "name": "YourCompany"},
- },
+ "id": profile.id,
+ "name": profile.name,
+ "warehouse": {
+ "id": profile.warehouse_id.id,
+ "name": profile.warehouse_id.name,
+ },
+ }
+ for profile in profiles
],
},
)
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index 77accd1a2d..2e489405c6 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -8,10 +8,16 @@ class CheckoutCommonCase(CommonCase):
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
cls.product_a = cls.env["product.product"].create(
- {"name": "Product A", "type": "product"}
+ {"name": "Product A", "type": "product", "barcode": "product_a"}
+ )
+ cls.product_a_packaging = cls.env["product.packaging"].create(
+ {"name": "Box", "product_id": cls.product_a.id, "barcode": "ProductABox"}
)
cls.product_b = cls.env["product.product"].create(
- {"name": "Product B", "type": "product"}
+ {"name": "Product B", "type": "product", "barcode": "product_b"}
+ )
+ cls.product_b_packaging = cls.env["product.packaging"].create(
+ {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox"}
)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout")
cls.process = cls.menu.process_id
@@ -26,16 +32,16 @@ def setUp(self):
self.service = work.component(usage="checkout")
@classmethod
- def _create_picking(cls, picking_type=None):
+ def _create_picking(cls, picking_type=None, lines=None):
picking_form = Form(cls.env["stock.picking"])
picking_form.picking_type_id = picking_type or cls.picking_type
picking_form.partner_id = cls.customer
- with picking_form.move_ids_without_package.new() as move:
- move.product_id = cls.product_a
- move.product_uom_qty = 10
- with picking_form.move_ids_without_package.new() as move:
- move.product_id = cls.product_b
- move.product_uom_qty = 10
+ if lines is None:
+ lines = [(cls.product_a, 10), (cls.product_b, 10)]
+ for product, qty in lines:
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = product
+ move.product_uom_qty = qty
picking = picking_form.save()
picking.action_confirm()
return picking
@@ -43,6 +49,9 @@ def _create_picking(cls, picking_type=None):
def _stock_picking_data(self, picking):
return self.service._data_for_stock_picking(picking)
+ def _move_line_data(self, move_line):
+ return self.service._data_for_move_line(move_line)
+
class CheckoutOpenAPICase(CheckoutCommonCase):
def test_to_openapi(self):
diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py
index 017ddf57e1..43ba2cc6dd 100644
--- a/shopfloor/tests/test_checkout_scan.py
+++ b/shopfloor/tests/test_checkout_scan.py
@@ -9,7 +9,9 @@ def _test_scan_ok(self, barcode_func):
barcode = barcode_func(picking)
response = self.service.dispatch("scan_document", params={"barcode": barcode})
self.assert_response(
- response, next_state="select_line", data=self._stock_picking_data(picking)
+ response,
+ next_state="select_line",
+ data={"picking": self._stock_picking_data(picking)},
)
def test_scan_document_stock_picking_ok(self):
diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py
new file mode 100644
index 0000000000..d0adae6322
--- /dev/null
+++ b/shopfloor/tests/test_checkout_scan_line.py
@@ -0,0 +1,109 @@
+from .test_checkout_base import CheckoutCommonCase
+
+
+class CheckoutScanLineCase(CheckoutCommonCase):
+ def _test_scan_line_ok(self, barcode, selected_lines):
+ picking = selected_lines.mapped("picking_id")
+ response = self.service.dispatch(
+ "scan_line", params={"picking_id": picking.id, "barcode": barcode}
+ )
+ for line in selected_lines:
+ self.assertEqual(
+ line.qty_done,
+ line.product_uom_qty,
+ "Scanned lines must have their qty done set to the reserved quantity",
+ )
+ self.assert_response(
+ response,
+ next_state="select_package",
+ data={
+ "selected_move_lines": [
+ self._move_line_data(ml) for ml in selected_lines
+ ],
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "note": "",
+ "origin": "",
+ "line_count": 2,
+ "partner": {"id": self.customer.id, "name": self.customer.name},
+ },
+ },
+ )
+
+ def test_scan_line_package_ok(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_b, 10)]
+ )
+ move1 = picking.move_lines[0]
+ move2 = picking.move_lines[1]
+ # put the lines in 2 separate packages (only the first line should be selected
+ # by the package barcode)
+ self._fill_stock_for_moves(move1, in_package=True)
+ self._fill_stock_for_moves(move2, in_package=True)
+ picking.action_assign()
+ move_line = move1.move_line_ids
+ self._test_scan_line_ok(move_line.package_id.name, move_line)
+
+ def test_scan_line_package_several_lines_ok(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_b, 10)]
+ )
+ # put all the lines in the same source package
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ package = picking.move_line_ids.mapped("package_id")
+ self._test_scan_line_ok(package.name, picking.move_line_ids)
+
+ def test_scan_line_product_ok(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_b, 10)]
+ )
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ line_a = picking.move_line_ids.filtered(
+ lambda l: l.product_id == self.product_a
+ )
+ # we have 2 different products in the picking, we scan the first
+ # one and expect to select the line
+ self._test_scan_line_ok(self.product_a.barcode, line_a)
+
+ def test_scan_line_product_several_lines_ok(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_a, 10), (self.product_b, 10)]
+ )
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ lines_a = picking.move_line_ids.filtered(
+ lambda l: l.product_id == self.product_a
+ )
+ # expect to select all the lines with the scanned product, as long
+ # as they are in the same package
+ self._test_scan_line_ok(self.product_a.barcode, lines_a)
+
+ def test_scan_line_product_packaging_ok(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_a, 10), (self.product_b, 10)]
+ )
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ lines_a = picking.move_line_ids.filtered(
+ lambda l: l.product_id == self.product_a
+ )
+ # when we scan the packaging of the product, we should select the
+ # lines as if the product was scanned
+ self._test_scan_line_ok(self.product_a_packaging.barcode, lines_a)
+
+ def test_scan_line_product_lot_ok(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 1), (self.product_a, 1), (self.product_b, 1)]
+ )
+ for move in picking.move_lines:
+ self._fill_stock_for_moves(move, in_lot=True)
+ picking.action_assign()
+ first_line = picking.move_line_ids[0]
+ lot = first_line.lot_id
+ self._test_scan_line_ok(lot.name, first_line)
+
+ # TODO test 2 lines with product in different packages
+ # TODO test 2 lines with lots in different packages
diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py
index bc37d3fc2c..8393a9763c 100644
--- a/shopfloor/tests/test_checkout_select.py
+++ b/shopfloor/tests/test_checkout_select.py
@@ -1,7 +1,7 @@
from .test_checkout_base import CheckoutCommonCase
-class CheckoutLisStockPickingCase(CheckoutCommonCase):
+class CheckoutListStockPickingCase(CheckoutCommonCase):
def test_list_stock_picking(self):
picking1 = self._create_picking()
picking2 = self._create_picking()
@@ -52,7 +52,7 @@ def test_select_ok(self):
self.assert_response(
response,
next_state="select_line",
- data=self._stock_picking_data(self.picking),
+ data={"picking": self._stock_picking_data(self.picking)},
)
def _test_error(self, picking, msg):
@@ -78,7 +78,7 @@ def _test_error(self, picking, msg):
def test_select_error_not_found(self):
picking = self._create_picking()
picking.unlink()
- self._test_error(picking, "This operation does not exist anymore.")
+ self._test_error(picking, "This transfer does not exist anymore.")
def test_select_error_not_available(self):
picking = self._create_picking()
diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py
index 98952c5c2e..685850002b 100644
--- a/shopfloor/tests/test_menu.py
+++ b/shopfloor/tests/test_menu.py
@@ -15,26 +15,21 @@ def test_menu_search(self):
"""Request /menu/search"""
# Simulate the client searching menus
response = self.service.dispatch("search")
+ menus = self.env["shopfloor.menu"].search([])
self.assert_response(
response,
data={
- "size": 3,
+ "size": len(menus),
"records": [
{
- "id": self.ANY,
- "name": "Put-Away Reach Truck",
- "process": {"id": self.ANY, "code": "single_pack_putaway"},
- },
- {
- "id": self.ANY,
- "name": "Single Pallet Transfer",
- "process": {"id": self.ANY, "code": "single_pack_transfer"},
- },
- {
- "id": self.ANY,
- "name": "Cluster Picking",
- "process": {"id": self.ANY, "code": "cluster_picking"},
- },
+ "id": menu.id,
+ "name": menu.name,
+ "process": {
+ "id": menu.process_id.id,
+ "code": menu.process_id.code,
+ },
+ }
+ for menu in menus
],
},
)
From 30c3570d090708b4af9ae86802d26a259cc35ed4 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 17 Mar 2020 14:55:30 +0100
Subject: [PATCH 150/940] checkout: /scan_line handle errors
---
shopfloor/actions/message.py | 16 ++
shopfloor/models/stock_move_line.py | 3 +
shopfloor/services/checkout.py | 124 ++++++++++---
shopfloor/tests/common.py | 10 +-
shopfloor/tests/test_checkout_base.py | 5 +-
shopfloor/tests/test_checkout_scan_line.py | 197 ++++++++++++++++++++-
6 files changed, 324 insertions(+), 31 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 63434a6b5d..2891e25c0d 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -187,3 +187,19 @@ def stock_picking_not_available(self, picking):
"message_type": "error",
"message": _("Transfer {} is not available.").format(picking.name),
}
+
+ def product_multiple_packages_scan_package(self):
+ return {
+ "message_type": "warning",
+ "message": _(
+ "This product is part of multiple packages, please scan a package."
+ ),
+ }
+
+ def lot_multiple_packages_scan_package(self):
+ return {
+ "message_type": "warning",
+ "message": _(
+ "This lot is part of multiple packages, please scan a package."
+ ),
+ }
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index f205aeaa01..0d772accc9 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -4,6 +4,7 @@
class StockMoveLine(models.Model):
_inherit = "stock.move.line"
+ # TODO use a serialized field
shopfloor_unloaded = fields.Boolean(default=False)
shopfloor_postponed = fields.Boolean(
default=False,
@@ -11,5 +12,7 @@ class StockMoveLine(models.Model):
help="Technical field. "
"Indicates if a the move has been postponed in a process.",
)
+ shopfloor_checkout_packed = fields.Boolean(default=False)
+
# we search lines based on their location in some workflows
location_id = fields.Many2one(index=True)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 76957097e8..b19fb4ab1e 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -6,8 +6,8 @@
from .service import to_float
# NOTE: we need to know if the destination package is set, but sometimes
-# the dest. package is kept, so we should have an additional field on
-# move lines to keep track of lines set
+# the dest. package is kept, so we will use field shopfloor_checkout_packed
+# on the move line
class Checkout(Component):
@@ -104,10 +104,11 @@ def _select_picking(self, picking, state_for_error):
# TODO if all lines have a dest package set, go to summary
return self._response_for_selected_stock_picking(picking)
- def _response_for_selected_stock_picking(self, picking):
+ def _response_for_selected_stock_picking(self, picking, message=None):
return self._response(
next_state="select_line",
data={"picking": self._data_for_stock_picking(picking)},
+ message=message,
)
def _response_for_picking_not_assigned(self, picking):
@@ -209,12 +210,18 @@ def _data_for_stock_picking(self, picking):
data.update(
{
"move_lines": [
- self._data_for_move_line(ml) for ml in picking.move_line_ids
+ self._data_for_move_line(ml)
+ for ml in self._lines_for_selection(picking)
]
}
)
return data
+ def _lines_for_selection(self, picking):
+ return picking.move_line_ids.filtered(
+ lambda l: l.qty_done == 0 and not l.shopfloor_checkout_packed
+ )
+
def _domain_for_list_stock_picking(self):
return [
("state", "=", "assigned"),
@@ -317,33 +324,108 @@ def scan_line(self, picking_id, barcode):
return self._response_stock_picking_does_not_exist()
search = self.actions_for("search")
+ message = self.actions_for("message")
+
+ selection_lines = self._lines_for_selection(picking)
+ # TODO handle no lines in selection go to summary
package = search.package_from_scan(barcode)
if package:
- lines = picking.move_line_ids.filtered(lambda l: l.package_id == package)
- if not lines:
- # TODO error package not in picking
- pass
- return self._select_scanned_lines(lines)
+ return self._select_lines_from_package(picking, selection_lines, package)
product = search.product_from_scan(barcode)
if product:
- # TODO product tracked by lot: must scan lot
- lines = picking.move_line_ids.filtered(lambda l: l.product_id == product)
- # TODO: if no lines: error
- if len(lines.mapped("package_id")) > 1:
- # TODO must scan package
- pass
- return self._select_scanned_lines(lines)
+ return self._select_lines_from_product(picking, selection_lines, product)
lot = search.lot_from_scan(barcode)
if lot:
- lines = picking.move_line_ids.filtered(lambda l: l.lot_id == lot)
- # TODO: if no lines: error
- return self._select_scanned_lines(lines)
+ return self._select_lines_from_lot(picking, selection_lines, lot)
- # TODO barcode not found
- return self._response()
+ return self._response_for_selected_stock_picking(
+ picking, message=message.barcode_not_found()
+ )
+
+ def _select_lines_from_package(self, picking, selection_lines, package):
+ lines = selection_lines.filtered(lambda l: l.package_id == package)
+ if not lines:
+ return self._response_for_selected_stock_picking(
+ picking,
+ message={
+ "message_type": "error",
+ "message": _("Package {} is not in the current transfer.").format(
+ package.name
+ ),
+ },
+ )
+ return self._select_scanned_lines(lines)
+
+ def _select_lines_from_product(self, picking, selection_lines, product):
+ message = self.actions_for("message")
+ if product.tracking in ("lot", "serial"):
+ return self._response_for_selected_stock_picking(
+ picking, message=message.scan_lot_on_product_tracked_by_lot()
+ )
+
+ lines = selection_lines.filtered(lambda l: l.product_id == product)
+ if not lines:
+ return self._response_for_selected_stock_picking(
+ picking,
+ message={
+ "message_type": "error",
+ "message": _("Product is not in the current transfer."),
+ },
+ )
+
+ # 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.
+ # If the product is only in one package though, scanning the product selects
+ # the package.
+ packages = lines.mapped("package_id")
+ # Do not use mapped here: we want to see if we have more than one package,
+ # but also if we have one product as a package and the same product as
+ # a unit in another line. In both cases, we want the user to scan the
+ # package.
+ if packages and len({l.package_id for l in lines}) > 1:
+ return self._response_for_selected_stock_picking(
+ picking, message=message.product_multiple_packages_scan_package()
+ )
+ elif packages:
+ # Select all the lines of the package when we scan a product in a
+ # package and we have only one.
+ return self._select_lines_from_package(picking, selection_lines, packages)
+
+ return self._select_scanned_lines(lines)
+
+ def _select_lines_from_lot(self, picking, selection_lines, lot):
+ lines = selection_lines.filtered(lambda l: l.lot_id == lot)
+ if not lines:
+ return self._response_for_selected_stock_picking(
+ picking,
+ message={
+ "message_type": "error",
+ "message": _("Lot is not in the current transfer."),
+ },
+ )
+
+ message = self.actions_for("message")
+ # When lots are as units outside of packages, we can select them for
+ # packing, but if they are in a package, we want the user to scan the packages.
+ # If the product is only in one package though, scanning the lot selects
+ # the package.
+ packages = lines.mapped("package_id")
+ # Do not use mapped here: we want to see if we have more than one
+ # package, but also if we have one lot as a package and the same lot as
+ # a unit in another line. In both cases, we want the user to scan the
+ # package.
+ if packages and len({l.package_id for l in lines}) > 1:
+ return self._response_for_selected_stock_picking(
+ picking, message=message.lot_multiple_packages_scan_package()
+ )
+ elif packages:
+ # Select all the lines of the package when we scan a lot in a
+ # package and we have only one.
+ return self._select_lines_from_package(picking, selection_lines, packages)
+ return self._select_scanned_lines(lines)
def select_line(self, picking_id, package_id=None, move_line_id=None):
"""Select move lines of the stock picking
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index aa11815b48..2ae7f007ee 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -2,6 +2,7 @@
from contextlib import contextmanager
from pprint import pformat
+from odoo import models
from odoo.tests.common import Form, SavepointCase
from odoo.addons.base_rest.controllers.main import _PseudoCollection
@@ -116,9 +117,12 @@ def _fill_stock_for_moves(cls, moves, in_package=False, in_lot=False):
for (product, location), qty in product_locations.items():
lot = None
if in_lot:
- lot = cls.env["stock.production.lot"].create(
- {"product_id": product.id, "company_id": cls.env.company.id}
- )
+ if isinstance(in_lot, models.BaseModel):
+ lot = in_lot
+ else:
+ lot = cls.env["stock.production.lot"].create(
+ {"product_id": product.id, "company_id": cls.env.company.id}
+ )
if not (in_lot or in_package):
# always add more quantity in stock to avoid to trigger the
# "zero checks" in tests, not for lots which must have a qty
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index 2e489405c6..e3bc09a5a0 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -32,7 +32,7 @@ def setUp(self):
self.service = work.component(usage="checkout")
@classmethod
- def _create_picking(cls, picking_type=None, lines=None):
+ def _create_picking(cls, picking_type=None, lines=None, confirm=True):
picking_form = Form(cls.env["stock.picking"])
picking_form.picking_type_id = picking_type or cls.picking_type
picking_form.partner_id = cls.customer
@@ -43,7 +43,8 @@ def _create_picking(cls, picking_type=None, lines=None):
move.product_id = product
move.product_uom_qty = qty
picking = picking_form.save()
- picking.action_confirm()
+ if confirm:
+ picking.action_confirm()
return picking
def _stock_picking_data(self, picking):
diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py
index d0adae6322..ea1ad70cae 100644
--- a/shopfloor/tests/test_checkout_scan_line.py
+++ b/shopfloor/tests/test_checkout_scan_line.py
@@ -3,6 +3,11 @@
class CheckoutScanLineCase(CheckoutCommonCase):
def _test_scan_line_ok(self, barcode, selected_lines):
+ """Test /scan_line with a valid return
+
+ :param barcode: the barcode we scan
+ :selected_lines: expected move lines returned by the endpoint
+ """
picking = selected_lines.mapped("picking_id")
response = self.service.dispatch(
"scan_line", params={"picking_id": picking.id, "barcode": barcode}
@@ -59,7 +64,8 @@ def test_scan_line_product_ok(self):
picking = self._create_picking(
lines=[(self.product_a, 10), (self.product_b, 10)]
)
- self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ # do not put them in a package, we'll pack units here
+ self._fill_stock_for_moves(picking.move_lines)
picking.action_assign()
line_a = picking.move_line_ids.filtered(
lambda l: l.product_id == self.product_a
@@ -72,7 +78,7 @@ def test_scan_line_product_several_lines_ok(self):
picking = self._create_picking(
lines=[(self.product_a, 10), (self.product_a, 10), (self.product_b, 10)]
)
- self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ self._fill_stock_for_moves(picking.move_lines)
picking.action_assign()
lines_a = picking.move_line_ids.filtered(
lambda l: l.product_id == self.product_a
@@ -85,7 +91,7 @@ def test_scan_line_product_packaging_ok(self):
picking = self._create_picking(
lines=[(self.product_a, 10), (self.product_a, 10), (self.product_b, 10)]
)
- self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ self._fill_stock_for_moves(picking.move_lines)
picking.action_assign()
lines_a = picking.move_line_ids.filtered(
lambda l: l.product_id == self.product_a
@@ -105,5 +111,186 @@ def test_scan_line_product_lot_ok(self):
lot = first_line.lot_id
self._test_scan_line_ok(lot.name, first_line)
- # TODO test 2 lines with product in different packages
- # TODO test 2 lines with lots in different packages
+ def test_scan_line_product_in_one_package_all_package_lines_ok(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_b, 10)]
+ )
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ # Product_a and product_b are in the same package, when we scan product_a,
+ # we expect to work on all the lines of the package. If product_a was in
+ # more than one package, it would be an error.
+ self._test_scan_line_ok(self.product_a.barcode, picking.move_line_ids)
+
+ def _test_scan_line_error(self, picking, barcode, message):
+ """Test errors for /scan_line
+
+ :param picking: the picking we are currently working with (selected)
+ :param barcode: the barcode we scan
+ """
+ response = self.service.dispatch(
+ "scan_line", params={"picking_id": picking.id, "barcode": barcode}
+ )
+ self.assert_response(
+ response,
+ next_state="select_line",
+ data={"picking": self._stock_picking_data(picking)},
+ message=message,
+ )
+
+ def test_scan_line_error_barcode_not_found(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ self._test_scan_line_error(
+ picking,
+ "NOT A BARCODE",
+ {"message_type": "error", "message": "Barcode not found"},
+ )
+
+ 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_lines, in_package=True)
+ picking2 = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(picking2.move_lines, in_package=True)
+ (picking | picking2).action_assign()
+ package = picking2.move_line_ids.package_id
+ # we work with picking, but we scan the package of picking2
+ self._test_scan_line_error(
+ picking,
+ package.name,
+ {
+ "message_type": "error",
+ "message": "Package {} is not in the current transfer.".format(
+ package.name
+ ),
+ },
+ )
+
+ def test_scan_line_error_product_tracked_by_lot(self):
+ self.product_a.tracking = "lot"
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ # product tracked by lot, but we scan the product barcode, user
+ # has to scan the lot
+ self._test_scan_line_error(
+ picking,
+ self.product_a.barcode,
+ {
+ "message_type": "warning",
+ "message": "Product tracked by lot, please scan one.",
+ },
+ )
+
+ def test_scan_line_error_product_in_two_packages(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_a, 10)],
+ # when action_confirm is called, it would merge the moves
+ confirm=False,
+ )
+ self._fill_stock_for_moves(picking.move_lines[0], in_package=True)
+ self._fill_stock_for_moves(picking.move_lines[1], in_package=True)
+ picking.action_assign()
+ self._test_scan_line_error(
+ picking,
+ self.product_a.barcode,
+ {
+ "message_type": "warning",
+ "message": "This product is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
+
+ def test_scan_line_error_product_in_one_package_and_unit(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_a, 10)],
+ # when action_confirm is called, it would merge the moves
+ # we want to keep them separated to put a part in a package
+ confirm=False,
+ )
+ # put the product in one package and the other as unit
+ self._fill_stock_for_moves(picking.move_lines[0], in_package=True)
+ self._fill_stock_for_moves(picking.move_lines[1])
+ picking.action_assign()
+ self._test_scan_line_error(
+ picking,
+ self.product_a.barcode,
+ {
+ "message_type": "warning",
+ "message": "This product is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
+
+ def test_scan_line_error_product_not_in_picking(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ self._test_scan_line_error(
+ picking,
+ self.product_b.barcode,
+ {
+ "message_type": "error",
+ "message": "Product is not in the current transfer.",
+ },
+ )
+
+ def test_scan_line_error_lot_not_in_picking(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(picking.move_lines, in_lot=True)
+ picking.action_assign()
+ lot = self.env["stock.production.lot"].create(
+ {"product_id": self.product_a.id, "company_id": self.env.company.id}
+ )
+ self._test_scan_line_error(
+ picking,
+ lot.name,
+ {"message_type": "error", "message": "Lot is not in the current transfer."},
+ )
+
+ def test_scan_line_error_lot_in_two_packages(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_a, 10)],
+ # when action_confirm is called, it would merge the moves
+ confirm=False,
+ )
+ # we want the same lot to be used in 2 lines with different packages
+ lot = self.env["stock.production.lot"].create(
+ {"product_id": self.product_a.id, "company_id": self.env.company.id}
+ )
+ self._fill_stock_for_moves(picking.move_lines[0], in_package=True, in_lot=lot)
+ self._fill_stock_for_moves(picking.move_lines[1], in_package=True, in_lot=lot)
+ picking.action_assign()
+ self._test_scan_line_error(
+ picking,
+ lot.name,
+ {
+ "message_type": "warning",
+ "message": "This lot is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
+
+ def test_scan_line_error_lot_in_one_package_and_unit(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_a, 10)],
+ # when action_confirm is called, it would merge the moves
+ confirm=False,
+ )
+ # we want the same lot to be used in 2 lines with different packages
+ lot = self.env["stock.production.lot"].create(
+ {"product_id": self.product_a.id, "company_id": self.env.company.id}
+ )
+ self._fill_stock_for_moves(picking.move_lines[0], in_package=True, in_lot=lot)
+ self._fill_stock_for_moves(picking.move_lines[1], in_lot=lot)
+ picking.action_assign()
+ self._test_scan_line_error(
+ picking,
+ lot.name,
+ {
+ "message_type": "warning",
+ "message": "This lot is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
From e302f8d9a67da8f4906d18caaa8f70a6649878ae Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 18 Mar 2020 11:13:37 +0100
Subject: [PATCH 151/940] checkout: implement / select_line
---
shopfloor/actions/message.py | 2 +-
shopfloor/services/checkout.py | 30 ++++++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_checkout_base.py | 6 ++
shopfloor/tests/test_checkout_scan_line.py | 32 ++++---
shopfloor/tests/test_checkout_select_line.py | 91 +++++++++++++++++++
.../tests/test_cluster_picking_select.py | 2 +-
7 files changed, 151 insertions(+), 13 deletions(-)
create mode 100644 shopfloor/tests/test_checkout_select_line.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 2891e25c0d..b2b6cc8433 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -77,7 +77,7 @@ def stock_picking_not_found(self):
def record_not_found(self):
return {
"message_type": "error",
- "message": _("This record you were working on does not exist anymore."),
+ "message": _("The record you were working on does not exist anymore."),
}
def barcode_not_found(self):
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index b19fb4ab1e..ce3ff645f6 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -444,6 +444,36 @@ def select_line(self, picking_id, package_id=None, move_line_id=None):
screen to change the qty done and destination package if needed
"""
assert package_id or move_line_id
+
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+
+ message = self.actions_for("message")
+ selection_lines = self._lines_for_selection(picking)
+ # TODO if no remaining lines, go to summary
+
+ if package_id:
+ package = self.env["stock.quant.package"].browse(package_id).exists()
+ if not package:
+ return self._response_for_selected_stock_picking(
+ picking, message=message.record_not_found()
+ )
+ return self._select_lines_from_package(picking, selection_lines, package)
+ if move_line_id:
+ move_line = self.env["stock.move.line"].browse(move_line_id).exists()
+ if not move_line:
+ return self._response_for_selected_stock_picking(
+ picking, message=message.record_not_found()
+ )
+ # normally, the client should sent only move lines out of packages, but
+ # in case there is a package, handle it as a package
+ if move_line.package_id:
+ return self._select_lines_from_package(
+ picking, selection_lines, move_line.package_id
+ )
+ return self._select_scanned_lines(move_line)
+
return self._response()
def reset_line_qty(self, picking_id, move_line_id):
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index e002c1d6cb..cf1bf3cd8e 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -13,3 +13,4 @@
from . import test_checkout_scan
from . import test_checkout_select
from . import test_checkout_scan_line
+from . import test_checkout_select_line
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index e3bc09a5a0..a3709d9329 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -19,6 +19,12 @@ def setUpClass(cls, *args, **kwargs):
cls.product_b_packaging = cls.env["product.packaging"].create(
{"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox"}
)
+ cls.product_c = cls.env["product.product"].create(
+ {"name": "Product C", "type": "product", "barcode": "product_c"}
+ )
+ cls.product_c_packaging = cls.env["product.packaging"].create(
+ {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox"}
+ )
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout")
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py
index ea1ad70cae..b645e97d1a 100644
--- a/shopfloor/tests/test_checkout_scan_line.py
+++ b/shopfloor/tests/test_checkout_scan_line.py
@@ -1,23 +1,18 @@
from .test_checkout_base import CheckoutCommonCase
-class CheckoutScanLineCase(CheckoutCommonCase):
- def _test_scan_line_ok(self, barcode, selected_lines):
- """Test /scan_line with a valid return
-
- :param barcode: the barcode we scan
- :selected_lines: expected move lines returned by the endpoint
- """
+class CheckoutSelectLineCommonCase(CheckoutCommonCase):
+ def _assert_selected(self, response, selected_lines):
picking = selected_lines.mapped("picking_id")
- response = self.service.dispatch(
- "scan_line", params={"picking_id": picking.id, "barcode": barcode}
- )
+ unselected_lines = picking.move_line_ids - selected_lines
for line in selected_lines:
self.assertEqual(
line.qty_done,
line.product_uom_qty,
"Scanned lines must have their qty done set to the reserved quantity",
)
+ for line in unselected_lines:
+ self.assertEqual(line.qty_done, 0)
self.assert_response(
response,
next_state="select_package",
@@ -30,12 +25,26 @@ def _test_scan_line_ok(self, barcode, selected_lines):
"name": picking.name,
"note": "",
"origin": "",
- "line_count": 2,
+ "line_count": len(picking.move_line_ids),
"partner": {"id": self.customer.id, "name": self.customer.name},
},
},
)
+
+class CheckoutScanLineCase(CheckoutSelectLineCommonCase):
+ def _test_scan_line_ok(self, barcode, selected_lines):
+ """Test /scan_line with a valid return
+
+ :param barcode: the barcode we scan
+ :selected_lines: expected move lines returned by the endpoint
+ """
+ picking = selected_lines.mapped("picking_id")
+ response = self.service.dispatch(
+ "scan_line", params={"picking_id": picking.id, "barcode": barcode}
+ )
+ self._assert_selected(response, selected_lines)
+
def test_scan_line_package_ok(self):
picking = self._create_picking(
lines=[(self.product_a, 10), (self.product_b, 10)]
@@ -127,6 +136,7 @@ def _test_scan_line_error(self, picking, barcode, message):
:param picking: the picking we are currently working with (selected)
:param barcode: the barcode we scan
+ :param message: the dict of expected error message
"""
response = self.service.dispatch(
"scan_line", params={"picking_id": picking.id, "barcode": barcode}
diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py
new file mode 100644
index 0000000000..22066b1384
--- /dev/null
+++ b/shopfloor/tests/test_checkout_select_line.py
@@ -0,0 +1,91 @@
+from .test_checkout_scan_line import CheckoutSelectLineCommonCase
+
+
+class CheckoutSelectLineCase(CheckoutSelectLineCommonCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ picking = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10)]
+ )
+ cls.moves_pack = picking.move_lines[:2]
+ cls.move_single = picking.move_lines[2:]
+ cls._fill_stock_for_moves(cls.moves_pack, in_package=True)
+ cls._fill_stock_for_moves(cls.move_single)
+ picking.action_assign()
+ cls.picking = picking
+
+ def test_select_line_package_ok(self):
+ selected_lines = self.moves_pack.move_line_ids
+ # we want to check that when we give the package id, we get
+ # all its move lines
+ response = self.service.dispatch(
+ "select_line",
+ params={
+ "picking_id": self.picking.id,
+ "package_id": selected_lines.package_id.id,
+ },
+ )
+ self._assert_selected(response, selected_lines)
+
+ def test_select_line_move_line_package_ok(self):
+ selected_lines = self.moves_pack.move_line_ids
+ # When we select a single line but the line is part of a package,
+ # we select all the lines. Note: not really supposed to happen as
+ # the client application should use send a package id when there is
+ # a package and use the move line id only for lines without package
+ response = self.service.dispatch(
+ "select_line",
+ params={
+ "picking_id": self.picking.id,
+ "move_line_id": selected_lines[0].id,
+ },
+ )
+ self._assert_selected(response, selected_lines)
+
+ def test_select_line_move_line_ok(self):
+ selected_lines = self.move_single.move_line_ids
+ response = self.service.dispatch(
+ "select_line",
+ params={
+ "picking_id": self.picking.id,
+ "move_line_id": selected_lines[0].id,
+ },
+ )
+ self._assert_selected(response, selected_lines)
+
+ def _test_select_line_error(self, params, message):
+ """Test errors for /select_line
+
+ :param params: params sent to /select_line
+ :param message: the dict of expected error message
+ """
+ response = self.service.dispatch("select_line", params=params)
+ self.assert_response(
+ response,
+ next_state="select_line",
+ data={"picking": self._stock_picking_data(self.picking)},
+ message=message,
+ )
+
+ def test_select_line_package_error_not_found(self):
+ selected_lines = self.move_single.move_line_ids
+ self.picking.do_unreserve()
+ self._test_select_line_error(
+ {"picking_id": self.picking.id, "package_id": selected_lines[0].id},
+ {
+ "message_type": "error",
+ "message": "The record you were working on does not exist anymore.",
+ },
+ )
+
+ def test_select_line_move_line_error_not_found(self):
+ selected_lines = self.move_single.move_line_ids
+ self.picking.do_unreserve()
+ self._test_select_line_error(
+ {"picking_id": self.picking.id, "move_line_id": selected_lines[0].id},
+ {
+ "message_type": "error",
+ "message": "The record you were working on does not exist anymore.",
+ },
+ )
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index af742af033..5bc64cd816 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -407,7 +407,7 @@ def test_confirm_start_not_exists(self):
response,
message={
"message_type": "error",
- "message": "This record you were working on does not exist anymore.",
+ "message": "The record you were working on does not exist anymore.",
},
next_state="start",
)
From 3bbe4dfb7e5359259c06cbc3a757831f89b85d3e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 18 Mar 2020 14:28:59 +0100
Subject: [PATCH 152/940] checkout: implement methods to change line quantities
---
shopfloor/services/checkout.py | 156 ++++++++++----
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_checkout_set_qty.py | 248 +++++++++++++++++++++++
3 files changed, 366 insertions(+), 39 deletions(-)
create mode 100644 shopfloor/tests/test_checkout_set_qty.py
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index ce3ff645f6..bf75100b58 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -210,14 +210,13 @@ def _data_for_stock_picking(self, picking):
data.update(
{
"move_lines": [
- self._data_for_move_line(ml)
- for ml in self._lines_for_selection(picking)
+ self._data_for_move_line(ml) for ml in self._lines_to_pack(picking)
]
}
)
return data
- def _lines_for_selection(self, picking):
+ def _lines_to_pack(self, picking):
return picking.move_line_ids.filtered(
lambda l: l.qty_done == 0 and not l.shopfloor_checkout_packed
)
@@ -283,7 +282,7 @@ def select(self, picking_id):
picking = self.env["stock.picking"].browse(picking_id).exists()
return self._select_picking(picking, "manual_selection")
- def _response_for_select_package(self, lines):
+ def _response_for_select_package(self, lines, message=None):
picking = lines.mapped("picking_id")
return self._response(
next_state="select_package",
@@ -293,12 +292,21 @@ def _response_for_select_package(self, lines):
],
"picking": self._data_picking_base(picking),
},
+ message=message,
)
- def _select_scanned_lines(self, lines):
+ def _select_lines(self, lines):
for line in lines:
+ if line.shopfloor_checkout_packed:
+ continue
line.qty_done = line.product_uom_qty
- return self._response_for_select_package(lines)
+
+ picking = lines.mapped("picking_id")
+ other_lines = picking.move_line_ids - lines
+ self._deselect_lines(other_lines)
+
+ def _deselect_lines(self, lines):
+ lines.filtered(lambda l: not l.shopfloor_checkout_packed).qty_done = 0
def scan_line(self, picking_id, barcode):
"""Scan move lines of the stock picking
@@ -326,7 +334,7 @@ def scan_line(self, picking_id, barcode):
search = self.actions_for("search")
message = self.actions_for("message")
- selection_lines = self._lines_for_selection(picking)
+ selection_lines = self._lines_to_pack(picking)
# TODO handle no lines in selection go to summary
package = search.package_from_scan(barcode)
@@ -357,7 +365,8 @@ def _select_lines_from_package(self, picking, selection_lines, package):
),
},
)
- return self._select_scanned_lines(lines)
+ self._select_lines(lines)
+ return self._response_for_select_package(lines)
def _select_lines_from_product(self, picking, selection_lines, product):
message = self.actions_for("message")
@@ -394,7 +403,8 @@ def _select_lines_from_product(self, picking, selection_lines, product):
# package and we have only one.
return self._select_lines_from_package(picking, selection_lines, packages)
- return self._select_scanned_lines(lines)
+ self._select_lines(lines)
+ return self._response_for_select_package(lines)
def _select_lines_from_lot(self, picking, selection_lines, lot):
lines = selection_lines.filtered(lambda l: l.lot_id == lot)
@@ -425,7 +435,9 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
# Select all the lines of the package when we scan a lot in a
# package and we have only one.
return self._select_lines_from_package(picking, selection_lines, packages)
- return self._select_scanned_lines(lines)
+
+ self._select_lines(lines)
+ return self._response_for_select_package(lines)
def select_line(self, picking_id, package_id=None, move_line_id=None):
"""Select move lines of the stock picking
@@ -450,7 +462,7 @@ def select_line(self, picking_id, package_id=None, move_line_id=None):
return self._response_stock_picking_does_not_exist()
message = self.actions_for("message")
- selection_lines = self._lines_for_selection(picking)
+ selection_lines = self._lines_to_pack(picking)
# TODO if no remaining lines, go to summary
if package_id:
@@ -472,31 +484,91 @@ def select_line(self, picking_id, package_id=None, move_line_id=None):
return self._select_lines_from_package(
picking, selection_lines, move_line.package_id
)
- return self._select_scanned_lines(move_line)
+ self._select_lines(move_line)
+ return self._response_for_select_package(move_line)
return self._response()
- def reset_line_qty(self, picking_id, move_line_id):
+ def _change_line_qty(
+ self, picking_id, selected_line_ids, move_line_id, quantity_func
+ ):
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+
+ message_directory = self.actions_for("message")
+
+ move_line = self.env["stock.move.line"].browse(move_line_id).exists()
+
+ message = None
+ if not move_line:
+ message = message_directory.record_not_found()
+ else:
+ qty_done = quantity_func(move_line)
+ if qty_done > move_line.product_uom_qty:
+ qty_done = move_line.product_uom_qty
+ message = {
+ "message": _(
+ "Not allowed to pack more than the quantity, "
+ "the value has been changed to the maximum."
+ ),
+ "message_type": "warning",
+ }
+ if qty_done < 0:
+ message = {
+ "message": _("Negative quantity not allowed."),
+ "message_type": "error",
+ }
+ else:
+ move_line.qty_done = qty_done
+ return self._response_for_select_package(
+ self.env["stock.move.line"].browse(selected_line_ids).exists(),
+ message=message,
+ )
+
+ def reset_line_qty(self, picking_id, selected_line_ids, move_line_id):
"""Reset qty_done of a move line to zero
Used to deselect a line in the "select_package" screen.
+ The selected_line_ids parameter is used to keep the selection of lines
+ stateless.
Transitions:
* select_package: goes back to the same state, the line will appear
as deselected
"""
- return self._response()
+ return self._change_line_qty(
+ picking_id, selected_line_ids, move_line_id, lambda __: 0
+ )
- def set_line_qty(self, picking_id, move_line_id):
+ def set_line_qty(self, picking_id, selected_line_ids, move_line_id):
"""Set qty_done of a move line to its reserved quantity
- Used to deselect a line in the "select_package" screen.
+ Used to select a line in the "select_package" screen.
+ The selected_line_ids parameter is used to keep the selection of lines
+ stateless.
Transitions:
* select_package: goes back to the same state, the line will appear
as selected
"""
- return self._response()
+ return self._change_line_qty(
+ picking_id, selected_line_ids, move_line_id, lambda l: l.product_uom_qty
+ )
+
+ def set_custom_qty(self, picking_id, selected_line_ids, move_line_id, qty_done):
+ """Change qty_done of a move line with a custom value
+
+ The selected_line_ids parameter is used to keep the selection of lines
+ stateless.
+
+ Transitions:
+ * select_package: goes back to this screen showing all the lines after
+ we changed the qty
+ """
+ return self._change_line_qty(
+ picking_id, selected_line_ids, move_line_id, lambda __: qty_done
+ )
def scan_package_action(self, picking_id, move_line_ids, barcode):
"""Scan a package, a lot, a product or a package to handle a line
@@ -530,15 +602,6 @@ def scan_package_action(self, picking_id, move_line_ids, barcode):
"""
return self._response()
- def set_custom_qty(self, picking_id, move_line_id, qty_done):
- """Change qty_done of a move line with a custom value
-
- Transitions:
- * select_package: goes back to this screen showing all the lines after
- we changed the qty
- """
- return self._response()
-
def new_package(self, picking_id, move_line_ids):
"""Add all selected lines in a new package
@@ -683,37 +746,52 @@ def select_line(self):
def reset_line_qty(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "selected_line_ids": {
+ "type": "list",
+ "required": True,
+ "schema": {"coerce": to_int, "required": True, "type": "integer"},
+ },
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
}
def set_line_qty(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "selected_line_ids": {
+ "type": "list",
+ "required": True,
+ "schema": {"coerce": to_int, "required": True, "type": "integer"},
+ },
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
}
- def scan_package_action(self):
+ def set_custom_qty(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
- "move_line_ids": {
+ "selected_line_ids": {
"type": "list",
"required": True,
"schema": {"coerce": to_int, "required": True, "type": "integer"},
},
- "barcode": {"required": True, "type": "string"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "qty_done": {"coerce": to_float, "required": True, "type": "float"},
}
- def set_custom_qty(self):
+ def scan_package_action(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
- "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
- "qty_done": {"coerce": to_float, "required": True, "type": "float"},
+ "selected_line_ids": {
+ "type": "list",
+ "required": True,
+ "schema": {"coerce": to_int, "required": True, "type": "integer"},
+ },
+ "barcode": {"required": True, "type": "string"},
}
def new_package(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
- "move_line_ids": {
+ "selected_line_ids": {
"type": "list",
"required": True,
"schema": {"coerce": to_int, "required": True, "type": "integer"},
@@ -723,7 +801,7 @@ def new_package(self):
def list_dest_package(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
- "move_line_ids": {
+ "selected_line_ids": {
"type": "list",
"required": True,
"schema": {"coerce": to_int, "required": True, "type": "integer"},
@@ -733,7 +811,7 @@ def list_dest_package(self):
def scan_dest_package(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
- "move_line_ids": {
+ "selected_line_ids": {
"type": "list",
"required": True,
"schema": {"coerce": to_int, "required": True, "type": "integer"},
@@ -744,7 +822,7 @@ def scan_dest_package(self):
def set_dest_package(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
- "move_line_ids": {
+ "selected_line_ids": {
"type": "list",
"required": True,
"schema": {"coerce": to_int, "required": True, "type": "integer"},
@@ -893,14 +971,14 @@ def reset_line_qty(self):
def set_line_qty(self):
return self._response_schema(next_states={"select_package"})
+ def set_custom_qty(self):
+ return self._response_schema(next_states={"select_package"})
+
def scan_package_action(self):
return self._response_schema(
next_states={"select_package", "select_line", "summary"}
)
- def set_custom_qty(self):
- return self._response_schema(next_states={"select_package"})
-
def new_package(self):
return self._response_schema(next_states={"select_line"})
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index cf1bf3cd8e..77713a88a2 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -14,3 +14,4 @@
from . import test_checkout_select
from . import test_checkout_scan_line
from . import test_checkout_select_line
+from . import test_checkout_set_qty
diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py
new file mode 100644
index 0000000000..35bc8b7819
--- /dev/null
+++ b/shopfloor/tests/test_checkout_set_qty.py
@@ -0,0 +1,248 @@
+from .test_checkout_base import CheckoutCommonCase
+
+
+class CheckoutSetQtyCommonCase(CheckoutCommonCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ picking = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10)]
+ )
+ cls.moves_pack1 = picking.move_lines[:2]
+ cls.moves_pack2 = picking.move_lines[2:]
+ cls._fill_stock_for_moves(cls.moves_pack1, in_package=True)
+ cls._fill_stock_for_moves(cls.moves_pack2, in_package=True)
+ picking.action_assign()
+ cls.picking = picking
+
+ def setUp(self):
+ super().setUp()
+ # we assume we have called /select_line on pack one, so by default, we
+ # expect the lines for product a and b to have their qty_done set to
+ # their product_uom_qty at the start of the tests
+ self.selected_lines = self.moves_pack1.move_line_ids
+ self.deselected_lines = self.moves_pack2.move_line_ids
+ self.service._select_lines(self.selected_lines)
+ self.assertTrue(
+ all(l.qty_done == l.product_uom_qty for l in self.selected_lines)
+ )
+ self.assertTrue(all(l.qty_done == 0 for l in self.deselected_lines))
+
+ def _assert_selected_qties(
+ self, response, selected_lines, lines_quantities, message=None
+ ):
+ picking = selected_lines.mapped("picking_id")
+ deselected_lines = picking.move_line_ids - selected_lines
+ self.assertEqual(selected_lines.ids, [l.id for l in lines_quantities])
+ for line, quantity in lines_quantities.items():
+ self.assertEqual(line.qty_done, quantity)
+ for line in deselected_lines:
+ self.assertEqual(line.qty_done, 0, "Lines deselected must have no qty done")
+ self.assert_response(
+ response,
+ next_state="select_package",
+ data={
+ "selected_move_lines": [
+ self._move_line_data(ml) for ml in selected_lines
+ ],
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "note": "",
+ "origin": "",
+ "line_count": len(picking.move_line_ids),
+ "partner": {"id": self.customer.id, "name": self.customer.name},
+ },
+ },
+ message=message,
+ )
+
+
+class CheckoutResetLineQtyCase(CheckoutSetQtyCommonCase):
+ def test_reset_line_qty_ok(self):
+ selected_lines = self.moves_pack1.move_line_ids
+ line_to_reset = selected_lines[0]
+ line_with_qty = selected_lines[1]
+ # we want to check that when we give the package id, we get
+ # all its move lines
+ response = self.service.dispatch(
+ "reset_line_qty",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": selected_lines.ids,
+ "move_line_id": line_to_reset.id,
+ },
+ )
+ self._assert_selected_qties(
+ response,
+ selected_lines,
+ {line_to_reset: 0, line_with_qty: line_with_qty.product_uom_qty},
+ )
+
+ def test_reset_line_qty_not_found(self):
+ selected_lines = self.moves_pack1.move_line_ids
+ response = self.service.dispatch(
+ "reset_line_qty",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": selected_lines.ids,
+ "move_line_id": 0,
+ },
+ )
+ # if the move line is not found, ignore and return to the
+ # screen
+ self._assert_selected_qties(
+ response,
+ selected_lines,
+ {line: line.product_uom_qty for line in selected_lines},
+ message={
+ "message": "The record you were working on does not exist anymore.",
+ "message_type": "error",
+ },
+ )
+
+
+class CheckoutSetLineQtyCase(CheckoutSetQtyCommonCase):
+ def test_set_line_qty_ok(self):
+ selected_lines = self.moves_pack1.move_line_ids
+ # do as if the user removed the qties of the 2 selected lines
+ selected_lines.qty_done = 0
+ line_to_set = selected_lines[0]
+ line_no_qty = selected_lines[1]
+ # we want to check that when we give the package id, we get
+ # all its move lines
+ response = self.service.dispatch(
+ "set_line_qty",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": selected_lines.ids,
+ "move_line_id": line_to_set.id,
+ },
+ )
+ self.assertEqual(line_to_set.qty_done, line_to_set.product_uom_qty)
+ self.assertEqual(line_no_qty.qty_done, 0)
+ self._assert_selected_qties(
+ response,
+ selected_lines,
+ {line_to_set: line_to_set.product_uom_qty, line_no_qty: 0},
+ )
+
+ def test_set_line_qty_not_found(self):
+ selected_lines = self.moves_pack1.move_line_ids
+ response = self.service.dispatch(
+ "set_line_qty",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": selected_lines.ids,
+ "move_line_id": 0,
+ },
+ )
+ # if the move line is not found, ignore and return to the
+ # screen
+ self._assert_selected_qties(
+ response,
+ selected_lines,
+ {line: line.product_uom_qty for line in selected_lines},
+ message={
+ "message": "The record you were working on does not exist anymore.",
+ "message_type": "error",
+ },
+ )
+
+
+class CheckoutSetCustomQtyCase(CheckoutSetQtyCommonCase):
+ def test_set_custom_qty_ok(self):
+ selected_lines = self.moves_pack1.move_line_ids
+ line_to_change = selected_lines[0]
+ line_keep_qty = selected_lines[1]
+ new_qty = 5
+ # we want to check that when we give the package id, we get
+ # all its move lines
+ response = self.service.dispatch(
+ "set_custom_qty",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": selected_lines.ids,
+ "move_line_id": line_to_change.id,
+ "qty_done": 5,
+ },
+ )
+ self.assertEqual(line_to_change.qty_done, new_qty)
+ self.assertEqual(line_keep_qty.qty_done, line_keep_qty.product_uom_qty)
+ self._assert_selected_qties(
+ response,
+ selected_lines,
+ {line_to_change: new_qty, line_keep_qty: line_keep_qty.product_uom_qty},
+ )
+
+ def test_set_custem_qty_not_found(self):
+ selected_lines = self.moves_pack1.move_line_ids
+ response = self.service.dispatch(
+ "set_custom_qty",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": selected_lines.ids,
+ "move_line_id": 0,
+ "qty_done": 3,
+ },
+ )
+ # if the move line is not found, ignore and return to the
+ # screen
+ self._assert_selected_qties(
+ response,
+ selected_lines,
+ {line: line.product_uom_qty for line in selected_lines},
+ message={
+ "message": "The record you were working on does not exist anymore.",
+ "message_type": "error",
+ },
+ )
+
+ def test_set_custom_qty_above(self):
+ selected_lines = self.moves_pack1.move_line_ids
+ line1 = selected_lines[0]
+ # modify so we can check that a too high quantity set the max
+ line1.qty_done = 1
+ line2 = selected_lines[1]
+ response = self.service.dispatch(
+ "set_custom_qty",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": selected_lines.ids,
+ "move_line_id": line1.id,
+ "qty_done": line1.product_uom_qty + 1,
+ },
+ )
+ self._assert_selected_qties(
+ response,
+ selected_lines,
+ {line1: line1.product_uom_qty, line2: line2.product_uom_qty},
+ message={
+ "message": "Not allowed to pack more than the quantity, "
+ "the value has been changed to the maximum.",
+ "message_type": "warning",
+ },
+ )
+
+ def test_set_custom_qty_negative(self):
+ selected_lines = self.moves_pack1.move_line_ids
+ line1 = selected_lines[0]
+ line2 = selected_lines[1]
+ response = self.service.dispatch(
+ "set_custom_qty",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": selected_lines.ids,
+ "move_line_id": line1.id,
+ "qty_done": -1,
+ },
+ )
+ self._assert_selected_qties(
+ response,
+ selected_lines,
+ {line1: line1.product_uom_qty, line2: line2.product_uom_qty},
+ message={
+ "message": "Negative quantity not allowed.",
+ "message_type": "error",
+ },
+ )
From 602c9322cb52f64c423c0edf087ae3f60721f2bb Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 20 Mar 2020 11:14:50 +0100
Subject: [PATCH 153/940] checkout: implement /scan_package_action and /summary
---
shopfloor/actions/search.py | 5 +
shopfloor/services/checkout.py | 198 +++++++--
shopfloor/tests/__init__.py | 2 +
shopfloor/tests/test_checkout_base.py | 6 +
shopfloor/tests/test_checkout_scan_line.py | 34 +-
.../test_checkout_scan_package_action.py | 378 ++++++++++++++++++
shopfloor/tests/test_checkout_select_line.py | 9 +-
.../test_checkout_select_package_base.py | 46 +++
shopfloor/tests/test_checkout_set_qty.py | 32 +-
shopfloor/tests/test_checkout_summary.py | 20 +
10 files changed, 628 insertions(+), 102 deletions(-)
create mode 100644 shopfloor/tests/test_checkout_scan_package_action.py
create mode 100644 shopfloor/tests/test_checkout_select_package_base.py
create mode 100644 shopfloor/tests/test_checkout_summary.py
diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py
index c170963b1d..a547abbbc6 100644
--- a/shopfloor/actions/search.py
+++ b/shopfloor/actions/search.py
@@ -32,3 +32,8 @@ def product_from_scan(self, barcode):
def lot_from_scan(self, barcode):
return self.env["stock.production.lot"].search([("name", "=", barcode)])
+
+ def generic_packaging_from_scan(self, barcode):
+ return self.env["product.packaging"].search(
+ [("barcode", "=", barcode), ("product_id", "=", False)]
+ )
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index bf75100b58..b7fc8c3e75 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -101,16 +101,24 @@ def _select_picking(self, picking, state_for_error):
message=message.stock_picking_not_available(picking)
)
return self._response_for_picking_not_assigned(picking)
- # TODO if all lines have a dest package set, go to summary
return self._response_for_selected_stock_picking(picking)
def _response_for_selected_stock_picking(self, picking, message=None):
+ if all(line.shopfloor_checkout_packed for line in picking.move_line_ids):
+ return self._response_for_all_lines_packed(picking, message=message)
return self._response(
next_state="select_line",
data={"picking": self._data_for_stock_picking(picking)},
message=message,
)
+ def _response_for_all_lines_packed(self, picking, message=None):
+ return self._response(
+ next_state="summary",
+ data={"picking": self._data_for_stock_picking(picking)},
+ message=message,
+ )
+
def _response_for_picking_not_assigned(self, picking):
message = self.actions_for("message")
return self._response(
@@ -439,6 +447,29 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
self._select_lines(lines)
return self._response_for_select_package(lines)
+ def _select_line_package(self, picking, selection_lines, package):
+ if not package:
+ message = self.actions_for("message")
+ return self._response_for_selected_stock_picking(
+ picking, message=message.record_not_found()
+ )
+ return self._select_lines_from_package(picking, selection_lines, package)
+
+ def _select_line_move_line(self, picking, selection_lines, move_line):
+ if not move_line:
+ message = self.actions_for("message")
+ return self._response_for_selected_stock_picking(
+ picking, message=message.record_not_found()
+ )
+ # normally, the client should sent only move lines out of packages, but
+ # in case there is a package, handle it as a package
+ if move_line.package_id:
+ return self._select_lines_from_package(
+ picking, selection_lines, move_line.package_id
+ )
+ self._select_lines(move_line)
+ return self._response_for_select_package(move_line)
+
def select_line(self, picking_id, package_id=None, move_line_id=None):
"""Select move lines of the stock picking
@@ -461,36 +492,18 @@ def select_line(self, picking_id, package_id=None, move_line_id=None):
if not picking.exists():
return self._response_stock_picking_does_not_exist()
- message = self.actions_for("message")
selection_lines = self._lines_to_pack(picking)
# TODO if no remaining lines, go to summary
if package_id:
package = self.env["stock.quant.package"].browse(package_id).exists()
- if not package:
- return self._response_for_selected_stock_picking(
- picking, message=message.record_not_found()
- )
- return self._select_lines_from_package(picking, selection_lines, package)
+ return self._select_line_package(picking, selection_lines, package)
if move_line_id:
move_line = self.env["stock.move.line"].browse(move_line_id).exists()
- if not move_line:
- return self._response_for_selected_stock_picking(
- picking, message=message.record_not_found()
- )
- # normally, the client should sent only move lines out of packages, but
- # in case there is a package, handle it as a package
- if move_line.package_id:
- return self._select_lines_from_package(
- picking, selection_lines, move_line.package_id
- )
- self._select_lines(move_line)
- return self._response_for_select_package(move_line)
-
- return self._response()
+ return self._select_line_move_line(picking, selection_lines, move_line)
def _change_line_qty(
- self, picking_id, selected_line_ids, move_line_id, quantity_func
+ self, picking_id, selected_line_ids, move_line_ids, quantity_func
):
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
@@ -498,12 +511,12 @@ def _change_line_qty(
message_directory = self.actions_for("message")
- move_line = self.env["stock.move.line"].browse(move_line_id).exists()
+ move_lines = self.env["stock.move.line"].browse(move_line_ids).exists()
message = None
- if not move_line:
+ if not move_lines:
message = message_directory.record_not_found()
- else:
+ for move_line in move_lines:
qty_done = quantity_func(move_line)
if qty_done > move_line.product_uom_qty:
qty_done = move_line.product_uom_qty
@@ -538,7 +551,7 @@ def reset_line_qty(self, picking_id, selected_line_ids, move_line_id):
as deselected
"""
return self._change_line_qty(
- picking_id, selected_line_ids, move_line_id, lambda __: 0
+ picking_id, selected_line_ids, [move_line_id], lambda __: 0
)
def set_line_qty(self, picking_id, selected_line_ids, move_line_id):
@@ -553,7 +566,7 @@ def set_line_qty(self, picking_id, selected_line_ids, move_line_id):
as selected
"""
return self._change_line_qty(
- picking_id, selected_line_ids, move_line_id, lambda l: l.product_uom_qty
+ picking_id, selected_line_ids, [move_line_id], lambda l: l.product_uom_qty
)
def set_custom_qty(self, picking_id, selected_line_ids, move_line_id, qty_done):
@@ -567,16 +580,91 @@ def set_custom_qty(self, picking_id, selected_line_ids, move_line_id, qty_done):
we changed the qty
"""
return self._change_line_qty(
- picking_id, selected_line_ids, move_line_id, lambda __: qty_done
+ picking_id, selected_line_ids, [move_line_id], lambda __: qty_done
)
- def scan_package_action(self, picking_id, move_line_ids, barcode):
+ def _switch_line_qty_done(self, picking, selected_lines, switch_lines):
+ """Switch qty_done on lines and return to the 'select_package' state
+
+ If at least one of the lines to switch has a qty_done, set them all
+ to zero. If all the lines to switch have a zero qty_done, switch them
+ to their quantity to deliver.
+ """
+ if any(line.qty_done for line in switch_lines):
+ return self._change_line_qty(
+ picking.id, selected_lines.ids, switch_lines.ids, lambda __: 0
+ )
+ else:
+ return self._change_line_qty(
+ picking.id,
+ selected_lines.ids,
+ switch_lines.ids,
+ lambda l: l.product_uom_qty,
+ )
+
+ @staticmethod
+ def _filter_lines_to_pack(move_line):
+ return move_line.qty_done > 0 and not move_line.shopfloor_checkout_packed
+
+ def _put_lines_in_package(self, picking, selected_lines, package):
+ """Put the current selected lines with a qty_done in a package
+
+ Note: only packages which are already a destination package for another
+ line of the stock picking can be selected. Packages which are the
+ source packages are allowed too (we keep the current package), but
+ since Odoo set the value of the result package to the source package by
+ default, it works by default.
+ """
+ existing_packages = picking.mapped("move_line_ids.result_package_id")
+ if package not in existing_packages:
+ return self._response_for_select_package(
+ selected_lines,
+ message={
+ "message_type": "error",
+ "message": _("Not a valid destination package").format(
+ package.name
+ ),
+ },
+ )
+ return self._put_lines_in_allowed_package(picking, selected_lines, package)
+
+ def _put_lines_in_allowed_package(self, picking, selected_lines, package):
+ lines_to_pack = selected_lines.filtered(self._filter_lines_to_pack)
+ lines_to_pack.write(
+ {"result_package_id": package.id, "shopfloor_checkout_packed": True}
+ )
+ # go back to the screen to select the next lines to pack
+ return self._response_for_selected_stock_picking(
+ picking,
+ message={
+ "message_type": "info",
+ "message": _("Product(s) packed in {}").format(package.name),
+ },
+ )
+
+ def _prepare_vals_package_from_packaging(self, packaging):
+ package_type = packaging.package_storage_type_id
+ return {
+ "package_storage_type_id": package_type.id,
+ "packaging_id": packaging.id,
+ "lngth": packaging.lngth,
+ "width": packaging.width,
+ "height": packaging.height,
+ }
+
+ def _create_and_assign_new_packaging(self, picking, selected_lines, packaging):
+ package = self.env["stock.quant.package"].create(
+ self._prepare_vals_package_from_packaging(packaging)
+ )
+ return self._put_lines_in_allowed_package(picking, selected_lines, package)
+
+ def scan_package_action(self, picking_id, selected_line_ids, barcode):
"""Scan a package, a lot, a product or a package to handle a line
When a package is scanned, if the package is known as the destination
package of one of the lines or is the source package of a selected
line, the package is set to be the destination package of all then
- selected lines.
+ lines to pack.
When a product is scanned, it selects (set qty_done = reserved qty) or
deselects (set qty_done = 0) the move lines for this product. Only
@@ -586,10 +674,11 @@ def scan_package_action(self, picking_id, move_line_ids, barcode):
on the lot.
When a packaging type (one without related product) is scanned, a new
- package is created and set as destination of the selected lines.
+ package is created and set as destination of the lines to pack.
- Selected lines are move lines in the list of ``move_line_ids`` where
- ``qty_done`` > 0 and have no destination package.
+ Lines to pack are move lines in the list of ``selected_line_ids``
+ where ``qty_done`` > 0 and have not been packed yet
+ (``shopfloor_checkout_packed is False``).
Transitions:
* select_package: when a product or lot is scanned to select/deselect,
@@ -600,9 +689,43 @@ def scan_package_action(self, picking_id, move_line_ids, barcode):
* summary: if there is no other lines, go to the summary screen to be able
to close the stock picking
"""
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+ search = self.actions_for("search")
+ message = self.actions_for("message")
+
+ selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
- def new_package(self, picking_id, move_line_ids):
+ product = search.product_from_scan(barcode)
+ if product:
+ if product.tracking in ("lot", "serial"):
+ return self._response_for_select_package(
+ selected_lines, message=message.scan_lot_on_product_tracked_by_lot()
+ )
+ product_lines = selected_lines.filtered(lambda l: l.product_id == product)
+ return self._switch_line_qty_done(picking, selected_lines, product_lines)
+
+ lot = search.lot_from_scan(barcode)
+ if lot:
+ lot_lines = selected_lines.filtered(lambda l: l.lot_id == lot)
+ return self._switch_line_qty_done(picking, selected_lines, lot_lines)
+
+ package = search.package_from_scan(barcode)
+ if package:
+ return self._put_lines_in_package(picking, selected_lines, package)
+
+ packaging = search.generic_packaging_from_scan(barcode)
+ if packaging:
+ return self._create_and_assign_new_packaging(
+ picking, selected_lines, packaging
+ )
+
+ return self._response_for_select_package(
+ selected_lines, message=message.barcode_not_found()
+ )
+
+ def new_package(self, picking_id, selected_line_ids):
"""Add all selected lines in a new package
It creates a new package and set it as the destination package of all
@@ -666,7 +789,10 @@ def summary(self, picking_id):
Transitions:
* summary
"""
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+ return self._response_for_all_lines_packed(picking)
def list_package_type(self, picking_id, package_id):
"""List the available package types for a package
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 77713a88a2..08f255a73e 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -15,3 +15,5 @@
from . import test_checkout_scan_line
from . import test_checkout_select_line
from . import test_checkout_set_qty
+from . import test_checkout_scan_package_action
+from . import test_checkout_summary
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index a3709d9329..0ad543068e 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -25,6 +25,12 @@ def setUpClass(cls, *args, **kwargs):
cls.product_c_packaging = cls.env["product.packaging"].create(
{"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox"}
)
+ cls.product_d = cls.env["product.product"].create(
+ {"name": "Product D", "type": "product", "barcode": "product_d"}
+ )
+ cls.product_d_packaging = cls.env["product.packaging"].create(
+ {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox"}
+ )
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout")
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py
index b645e97d1a..4344b8f8a9 100644
--- a/shopfloor/tests/test_checkout_scan_line.py
+++ b/shopfloor/tests/test_checkout_scan_line.py
@@ -1,38 +1,8 @@
from .test_checkout_base import CheckoutCommonCase
+from .test_checkout_select_package_base import CheckoutSelectPackageMixin
-class CheckoutSelectLineCommonCase(CheckoutCommonCase):
- def _assert_selected(self, response, selected_lines):
- picking = selected_lines.mapped("picking_id")
- unselected_lines = picking.move_line_ids - selected_lines
- for line in selected_lines:
- self.assertEqual(
- line.qty_done,
- line.product_uom_qty,
- "Scanned lines must have their qty done set to the reserved quantity",
- )
- for line in unselected_lines:
- self.assertEqual(line.qty_done, 0)
- self.assert_response(
- response,
- next_state="select_package",
- data={
- "selected_move_lines": [
- self._move_line_data(ml) for ml in selected_lines
- ],
- "picking": {
- "id": picking.id,
- "name": picking.name,
- "note": "",
- "origin": "",
- "line_count": len(picking.move_line_ids),
- "partner": {"id": self.customer.id, "name": self.customer.name},
- },
- },
- )
-
-
-class CheckoutScanLineCase(CheckoutSelectLineCommonCase):
+class CheckoutScanLineCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
def _test_scan_line_ok(self, barcode, selected_lines):
"""Test /scan_line with a valid return
diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py
new file mode 100644
index 0000000000..9aa13fd6e3
--- /dev/null
+++ b/shopfloor/tests/test_checkout_scan_package_action.py
@@ -0,0 +1,378 @@
+from itertools import product
+
+from .test_checkout_base import CheckoutCommonCase
+from .test_checkout_select_package_base import CheckoutSelectPackageMixin
+
+
+class CheckoutScanPackageActionCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
+ def _test_select_product(
+ self, barcode_func, origin_qty_func, expected_qty_func, in_lot=False
+ ):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_b, 10), (self.product_c, 10)]
+ )
+ for move_line in picking.move_lines:
+ # put in 3 different packages
+ self._fill_stock_for_moves(move_line, in_package=True, in_lot=in_lot)
+ picking.action_assign()
+
+ # we have selected the pack that contains product a
+ line_a = picking.move_line_ids[0]
+ line_a.qty_done = origin_qty_func(line_a)
+
+ response = self.service.dispatch(
+ "scan_package_action",
+ params={
+ "picking_id": picking.id,
+ "selected_line_ids": line_a.ids,
+ "barcode": barcode_func(line_a),
+ },
+ )
+
+ # since we scanned the barcode of the product and we had a
+ # qty_done, the qty_done should flip to 0
+ self._assert_selected_qties(
+ response, line_a, {line_a: expected_qty_func(line_a)}
+ )
+
+ def test_scan_package_action_select_product(self):
+ self._test_select_product(
+ lambda l: l.product_id.barcode, lambda l: l.product_uom_qty, lambda __: 0
+ )
+
+ def test_scan_package_action_deselect_product(self):
+ self._test_select_product(
+ lambda l: l.product_id.barcode, lambda __: 0, lambda l: l.product_uom_qty
+ )
+
+ def test_scan_package_action_select_product_packaging(self):
+ self._test_select_product(
+ lambda l: l.product_id.packaging_ids.barcode,
+ lambda l: l.product_uom_qty,
+ lambda __: 0,
+ )
+
+ def test_scan_package_action_deselect_product_packaging(self):
+ self._test_select_product(
+ lambda l: l.product_id.packaging_ids.barcode,
+ lambda __: 0,
+ lambda l: l.product_uom_qty,
+ )
+
+ def test_scan_package_action_select_product_lot(self):
+ self._test_select_product(
+ lambda l: l.lot_id.name,
+ lambda __: 0,
+ lambda l: l.product_uom_qty,
+ in_lot=True,
+ )
+
+ def test_scan_package_action_deselect_product_lot(self):
+ self._test_select_product(
+ lambda l: l.lot_id.name,
+ lambda l: l.product_uom_qty,
+ lambda __: 0,
+ in_lot=True,
+ )
+
+ def _test_scan_package_action_scan_product_error_tracked_by(
+ self, tracked_by, barcode
+ ):
+ self.product_a.tracking = tracked_by
+ picking = self._create_picking(lines=[(self.product_a, 1)])
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ move_line = picking.move_line_ids
+ origin_qty_done = move_line.qty_done
+ response = self.service.dispatch(
+ "scan_package_action",
+ params={
+ "picking_id": picking.id,
+ "selected_line_ids": move_line.ids,
+ "barcode": barcode,
+ },
+ )
+ self._assert_selected_qties(
+ response,
+ move_line,
+ # no change as the scan was not valid
+ {move_line: origin_qty_done},
+ message={
+ "message_type": "warning",
+ "message": "Product tracked by lot, please scan one.",
+ },
+ )
+
+ def test_scan_package_action_scan_product_error_tracking(self):
+ trackings = ("lot", "serial")
+ barcodes = (self.product_a.barcode, self.product_a.packaging_ids.barcode)
+ for tracking, barcode in product(trackings, barcodes):
+ self._test_scan_package_action_scan_product_error_tracked_by(
+ tracking, barcode
+ )
+
+ def test_scan_package_action_scan_package_keep_source_package_ok(self):
+ picking = self._create_picking(
+ lines=[
+ (self.product_a, 10),
+ (self.product_b, 10),
+ (self.product_c, 10),
+ (self.product_d, 10),
+ ]
+ )
+ pack1_moves = picking.move_lines[:3]
+ pack2_moves = picking.move_lines[3:]
+ # put in 2 packs, for this test, we'll work on pack1
+ self._fill_stock_for_moves(pack1_moves, in_package=True)
+ self._fill_stock_for_moves(pack2_moves, in_package=True)
+ picking.action_assign()
+
+ selected_lines = pack1_moves.move_line_ids
+ pack1 = pack1_moves.move_line_ids.package_id
+
+ move_line1, move_line2, move_line3 = selected_lines
+ # We'll put only product A and B in the package
+ move_line1.qty_done = move_line1.product_uom_qty
+ move_line2.qty_done = move_line2.product_uom_qty
+ move_line3.qty_done = 0
+
+ response = self.service.dispatch(
+ "scan_package_action",
+ params={
+ "picking_id": picking.id,
+ "selected_line_ids": selected_lines.ids,
+ # we keep the goods in the same package, so we scan the source package
+ "barcode": pack1.name,
+ },
+ )
+
+ self.assertRecordValues(
+ move_line1,
+ [{"result_package_id": pack1.id, "shopfloor_checkout_packed": True}],
+ )
+ self.assertRecordValues(
+ move_line2,
+ [{"result_package_id": pack1.id, "shopfloor_checkout_packed": True}],
+ )
+ self.assertRecordValues(
+ move_line3,
+ # qty_done was zero so we don't set it as packed
+ [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}],
+ )
+ self.assert_response(
+ response,
+ # go pack to the screen to select lines to put in packages
+ next_state="select_line",
+ data={"picking": self._stock_picking_data(picking)},
+ message={
+ "message_type": "info",
+ "message": "Product(s) packed in {}".format(pack1.name),
+ },
+ )
+
+ def test_scan_package_action_scan_package_error_invalid(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ move = picking.move_lines
+ self._fill_stock_for_moves(move, in_package=True)
+ picking.action_assign()
+
+ selected_line = move.move_line_ids
+ other_package = self.env["stock.quant.package"].create({})
+
+ response = self.service.dispatch(
+ "scan_package_action",
+ params={
+ "picking_id": picking.id,
+ "selected_line_ids": selected_line.ids,
+ "barcode": other_package.name,
+ },
+ )
+
+ self.assertRecordValues(
+ selected_line,
+ [
+ {
+ # the result package must remain identical, so equal to the
+ # source package
+ "result_package_id": selected_line.package_id.id,
+ "shopfloor_checkout_packed": False,
+ }
+ ],
+ )
+ self._assert_selected_response(
+ response,
+ selected_line,
+ message={
+ "message_type": "error",
+ "message": "Not a valid destination package",
+ },
+ )
+
+ def test_scan_package_action_scan_package_use_existing_package_ok(self):
+ picking = self._create_picking(
+ lines=[
+ (self.product_a, 10),
+ (self.product_b, 10),
+ (self.product_c, 10),
+ (self.product_d, 10),
+ ]
+ )
+ pack1_moves = picking.move_lines[:3]
+ pack2_moves = picking.move_lines[3:]
+ # put in 2 packs, for this test, we'll work on pack1
+ self._fill_stock_for_moves(pack1_moves, in_package=True)
+ self._fill_stock_for_moves(pack2_moves, in_package=True)
+ picking.action_assign()
+
+ package = self.env["stock.quant.package"].create({})
+
+ # assume that product d was already put in a package,
+ # we must be able to put the lines of pack1 inside the same
+ pack2_moves.move_line_ids.write(
+ {"result_package_id": package.id, "shopfloor_checkout_packed": True}
+ )
+
+ selected_lines = pack1_moves.move_line_ids
+ # they are all selected
+ selected_lines.write({"qty_done": 10.0})
+
+ response = self.service.dispatch(
+ "scan_package_action",
+ params={
+ "picking_id": picking.id,
+ "selected_line_ids": selected_lines.ids,
+ # use the package that was used for product D
+ "barcode": package.name,
+ },
+ )
+
+ self.assertRecordValues(
+ selected_lines,
+ [
+ {"result_package_id": package.id, "shopfloor_checkout_packed": True},
+ {"result_package_id": package.id, "shopfloor_checkout_packed": True},
+ {"result_package_id": package.id, "shopfloor_checkout_packed": True},
+ ],
+ )
+
+ self.assert_response(
+ response,
+ # all the lines are packed, so we expect to go the summary screen
+ next_state="summary",
+ data={"picking": self._stock_picking_data(picking)},
+ message={
+ "message_type": "info",
+ "message": "Product(s) packed in {}".format(package.name),
+ },
+ )
+
+ def test_scan_package_action_scan_packaging_ok(self):
+ picking = self._create_picking(
+ lines=[
+ (self.product_a, 10),
+ (self.product_b, 10),
+ (self.product_c, 10),
+ (self.product_d, 10),
+ ]
+ )
+ pack1_moves = picking.move_lines[:3]
+ pack2_moves = picking.move_lines[3:]
+ # put in 2 packs, for this test, we'll work on pack1
+ self._fill_stock_for_moves(pack1_moves, in_package=True)
+ self._fill_stock_for_moves(pack2_moves, in_package=True)
+ picking.action_assign()
+
+ selected_lines = pack1_moves.move_line_ids
+ pack1 = pack1_moves.move_line_ids.package_id
+
+ move_line1, move_line2, move_line3 = selected_lines
+ # we'll put only the first 2 lines (product A and B) in the new package
+ move_line1.qty_done = move_line1.product_uom_qty
+ move_line2.qty_done = move_line2.product_uom_qty
+ move_line3.qty_done = 0
+
+ packaging_type = self.env["stock.package.storage.type"].create(
+ {"name": "Pallet"}
+ )
+ packaging = self.env["product.packaging"].create(
+ {
+ "name": "Pallet",
+ "barcode": "PPP",
+ "height": 12,
+ "width": 13,
+ "lngth": 14,
+ "package_storage_type_id": packaging_type.id,
+ }
+ )
+
+ response = self.service.dispatch(
+ "scan_package_action",
+ params={
+ "picking_id": picking.id,
+ "selected_line_ids": selected_lines.ids,
+ # create a new package using this packaging
+ "barcode": packaging.barcode,
+ },
+ )
+
+ new_package = move_line1.result_package_id
+ self.assertNotEqual(pack1, new_package)
+
+ self.assertRecordValues(
+ new_package,
+ [
+ {
+ "package_storage_type_id": packaging_type.id,
+ "packaging_id": packaging.id,
+ "lngth": packaging.lngth,
+ "width": packaging.width,
+ "height": packaging.height,
+ }
+ ],
+ )
+
+ self.assertRecordValues(
+ move_line1,
+ [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}],
+ )
+ self.assertRecordValues(
+ move_line2,
+ [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}],
+ )
+ self.assertRecordValues(
+ move_line3,
+ # qty_done was zero so we don't set it as packed and it remains in
+ # the same package
+ [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}],
+ )
+ self.assert_response(
+ response,
+ # go pack to the screen to select lines to put in packages
+ next_state="select_line",
+ data={"picking": self._stock_picking_data(picking)},
+ message={
+ "message_type": "info",
+ "message": "Product(s) packed in {}".format(new_package.name),
+ },
+ )
+
+ def test_scan_package_action_scan_not_found(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ move = picking.move_lines
+ self._fill_stock_for_moves(move, in_package=True)
+ picking.action_assign()
+ selected_line = move.move_line_ids
+ response = self.service.dispatch(
+ "scan_package_action",
+ params={
+ "picking_id": picking.id,
+ "selected_line_ids": selected_line.ids,
+ # create a new package using this packaging
+ "barcode": "BARCODE NOT FOUND",
+ },
+ )
+ self._assert_selected_response(
+ response,
+ selected_line,
+ message={"message_type": "error", "message": "Barcode not found"},
+ )
diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py
index 22066b1384..5db7c188a1 100644
--- a/shopfloor/tests/test_checkout_select_line.py
+++ b/shopfloor/tests/test_checkout_select_line.py
@@ -1,7 +1,8 @@
-from .test_checkout_scan_line import CheckoutSelectLineCommonCase
+from .test_checkout_base import CheckoutCommonCase
+from .test_checkout_select_package_base import CheckoutSelectPackageMixin
-class CheckoutSelectLineCase(CheckoutSelectLineCommonCase):
+class CheckoutSelectLineCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
@classmethod
def setUpClass(cls):
super().setUpClass()
@@ -70,7 +71,7 @@ def _test_select_line_error(self, params, message):
def test_select_line_package_error_not_found(self):
selected_lines = self.move_single.move_line_ids
- self.picking.do_unreserve()
+ selected_lines.unlink()
self._test_select_line_error(
{"picking_id": self.picking.id, "package_id": selected_lines[0].id},
{
@@ -81,7 +82,7 @@ def test_select_line_package_error_not_found(self):
def test_select_line_move_line_error_not_found(self):
selected_lines = self.move_single.move_line_ids
- self.picking.do_unreserve()
+ selected_lines.unlink()
self._test_select_line_error(
{"picking_id": self.picking.id, "move_line_id": selected_lines[0].id},
{
diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py
new file mode 100644
index 0000000000..4a66c075e4
--- /dev/null
+++ b/shopfloor/tests/test_checkout_select_package_base.py
@@ -0,0 +1,46 @@
+class CheckoutSelectPackageMixin:
+ def _assert_selected_response(self, response, selected_lines, message=None):
+ picking = selected_lines.mapped("picking_id")
+ self.assert_response(
+ response,
+ next_state="select_package",
+ data={
+ "selected_move_lines": [
+ self._move_line_data(ml) for ml in selected_lines
+ ],
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "note": "",
+ "origin": "",
+ "line_count": len(picking.move_line_ids),
+ "partner": {"id": self.customer.id, "name": self.customer.name},
+ },
+ },
+ message=message,
+ )
+
+ def _assert_selected_qties(
+ self, response, selected_lines, lines_quantities, message=None
+ ):
+ picking = selected_lines.mapped("picking_id")
+ deselected_lines = picking.move_line_ids - selected_lines
+ self.assertEqual(selected_lines.ids, [l.id for l in lines_quantities])
+ for line, quantity in lines_quantities.items():
+ self.assertEqual(line.qty_done, quantity)
+ for line in deselected_lines:
+ self.assertEqual(line.qty_done, 0, "Lines deselected must have no qty done")
+ self._assert_selected_response(response, selected_lines, message=message)
+
+ def _assert_selected(self, response, selected_lines, message=None):
+ picking = selected_lines.mapped("picking_id")
+ unselected_lines = picking.move_line_ids - selected_lines
+ for line in selected_lines:
+ self.assertEqual(
+ line.qty_done,
+ line.product_uom_qty,
+ "Scanned lines must have their qty done set to the reserved quantity",
+ )
+ for line in unselected_lines:
+ self.assertEqual(line.qty_done, 0)
+ self._assert_selected_response(response, selected_lines, message=message)
diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py
index 35bc8b7819..e2df7952a3 100644
--- a/shopfloor/tests/test_checkout_set_qty.py
+++ b/shopfloor/tests/test_checkout_set_qty.py
@@ -1,7 +1,8 @@
from .test_checkout_base import CheckoutCommonCase
+from .test_checkout_select_package_base import CheckoutSelectPackageMixin
-class CheckoutSetQtyCommonCase(CheckoutCommonCase):
+class CheckoutSetQtyCommonCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
@classmethod
def setUpClass(cls):
super().setUpClass()
@@ -28,35 +29,6 @@ def setUp(self):
)
self.assertTrue(all(l.qty_done == 0 for l in self.deselected_lines))
- def _assert_selected_qties(
- self, response, selected_lines, lines_quantities, message=None
- ):
- picking = selected_lines.mapped("picking_id")
- deselected_lines = picking.move_line_ids - selected_lines
- self.assertEqual(selected_lines.ids, [l.id for l in lines_quantities])
- for line, quantity in lines_quantities.items():
- self.assertEqual(line.qty_done, quantity)
- for line in deselected_lines:
- self.assertEqual(line.qty_done, 0, "Lines deselected must have no qty done")
- self.assert_response(
- response,
- next_state="select_package",
- data={
- "selected_move_lines": [
- self._move_line_data(ml) for ml in selected_lines
- ],
- "picking": {
- "id": picking.id,
- "name": picking.name,
- "note": "",
- "origin": "",
- "line_count": len(picking.move_line_ids),
- "partner": {"id": self.customer.id, "name": self.customer.name},
- },
- },
- message=message,
- )
-
class CheckoutResetLineQtyCase(CheckoutSetQtyCommonCase):
def test_reset_line_qty_ok(self):
diff --git a/shopfloor/tests/test_checkout_summary.py b/shopfloor/tests/test_checkout_summary.py
new file mode 100644
index 0000000000..c58803c11c
--- /dev/null
+++ b/shopfloor/tests/test_checkout_summary.py
@@ -0,0 +1,20 @@
+from .test_checkout_base import CheckoutCommonCase
+
+
+class CheckoutSummaryCase(CheckoutCommonCase):
+ def test_summary_ok(self):
+ picking = self._create_picking(
+ lines=[
+ (self.product_a, 10),
+ (self.product_b, 10),
+ (self.product_c, 10),
+ (self.product_d, 10),
+ ]
+ )
+ response = self.service.dispatch("summary", params={"picking_id": picking.id})
+
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={"picking": self._stock_picking_data(picking)},
+ )
From 3fb0ae5b36055f690f1c39ecb37cb6198fc756c7 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 20 Mar 2020 11:30:48 +0100
Subject: [PATCH 154/940] checkout: implement /new_package
---
shopfloor/services/checkout.py | 22 +++++--
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_checkout_new_package.py | 62 ++++++++++++++++++++
3 files changed, 79 insertions(+), 6 deletions(-)
create mode 100644 shopfloor/tests/test_checkout_new_package.py
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index b7fc8c3e75..916c779d08 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -652,10 +652,15 @@ def _prepare_vals_package_from_packaging(self, packaging):
"height": packaging.height,
}
- def _create_and_assign_new_packaging(self, picking, selected_lines, packaging):
- package = self.env["stock.quant.package"].create(
- self._prepare_vals_package_from_packaging(packaging)
- )
+ def _prepare_vals_package_without_packaging(self):
+ return {}
+
+ def _create_and_assign_new_packaging(self, picking, selected_lines, packaging=None):
+ if packaging:
+ vals = self._prepare_vals_package_from_packaging(packaging)
+ else:
+ vals = self._prepare_vals_package_without_packaging()
+ package = self.env["stock.quant.package"].create(vals)
return self._put_lines_in_allowed_package(picking, selected_lines, package)
def scan_package_action(self, picking_id, selected_line_ids, barcode):
@@ -732,12 +737,17 @@ def new_package(self, picking_id, selected_line_ids):
the selected lines.
Selected lines are move lines in the list of ``move_line_ids`` where
- ``qty_done`` > 0 and have no destination package.
+ ``qty_done`` > 0 and have no destination package
+ (shopfloor_checkout_packed is False).
Transitions:
* select_line: goes back to selection of lines to work on next lines
"""
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+ selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
+ return self._create_and_assign_new_packaging(picking, selected_lines)
def list_dest_package(self, picking_id, move_line_ids):
"""Return a list of packages the user can select for the lines
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 08f255a73e..920e2190e5 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -16,4 +16,5 @@
from . import test_checkout_select_line
from . import test_checkout_set_qty
from . import test_checkout_scan_package_action
+from . import test_checkout_new_package
from . import test_checkout_summary
diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py
new file mode 100644
index 0000000000..bd62d52c49
--- /dev/null
+++ b/shopfloor/tests/test_checkout_new_package.py
@@ -0,0 +1,62 @@
+from .test_checkout_base import CheckoutCommonCase
+from .test_checkout_select_package_base import CheckoutSelectPackageMixin
+
+
+class CheckoutNewPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
+ def test_new_package_ok(self):
+ picking = self._create_picking(
+ lines=[
+ (self.product_a, 10),
+ (self.product_b, 10),
+ (self.product_c, 10),
+ (self.product_d, 10),
+ ]
+ )
+ pack1_moves = picking.move_lines[:3]
+ pack2_moves = picking.move_lines[3:]
+ # put in 2 packs, for this test, we'll work on pack1
+ self._fill_stock_for_moves(pack1_moves, in_package=True)
+ self._fill_stock_for_moves(pack2_moves, in_package=True)
+ picking.action_assign()
+
+ selected_lines = pack1_moves.move_line_ids
+ pack1 = pack1_moves.move_line_ids.package_id
+
+ move_line1, move_line2, move_line3 = selected_lines
+ # we'll put only the first 2 lines (product A and B) in the new package
+ move_line1.qty_done = move_line1.product_uom_qty
+ move_line2.qty_done = move_line2.product_uom_qty
+ move_line3.qty_done = 0
+
+ response = self.service.dispatch(
+ "new_package",
+ params={"picking_id": picking.id, "selected_line_ids": selected_lines.ids},
+ )
+
+ new_package = move_line1.result_package_id
+ self.assertNotEqual(pack1, new_package)
+
+ self.assertRecordValues(
+ move_line1,
+ [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}],
+ )
+ self.assertRecordValues(
+ move_line2,
+ [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}],
+ )
+ self.assertRecordValues(
+ move_line3,
+ # qty_done was zero so we don't set it as packed and it remains in
+ # the same package
+ [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}],
+ )
+ self.assert_response(
+ response,
+ # go pack to the screen to select lines to put in packages
+ next_state="select_line",
+ data={"picking": self._stock_picking_data(picking)},
+ message={
+ "message_type": "info",
+ "message": "Product(s) packed in {}".format(new_package.name),
+ },
+ )
From 5f7966364367e5bdc26e0cc624751d02f23241bd Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 20 Mar 2020 11:40:16 +0100
Subject: [PATCH 155/940] checkout: add TODO comments
---
shopfloor/services/checkout.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 916c779d08..2a72de2dd4 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -758,6 +758,8 @@ def list_dest_package(self, picking_id, move_line_ids):
Transitions:
* select_dest_package: selection screen
"""
+ # TODO get list of all result_package_id | package_id of picking's move
+ # lines, return to 'select_dest_package' with the list
return self._response()
def scan_dest_package(self, picking_id, move_line_ids, barcode):
@@ -777,6 +779,8 @@ def scan_dest_package(self, picking_id, move_line_ids, barcode):
* select_line: lines to package remain
* summary: all lines are put in packages
"""
+ # TODO search for stock.quant.package with barcode, if found, call
+ # _put_lines_in_package
return self._response()
def set_dest_package(self, picking_id, move_line_ids, package_id):
@@ -791,6 +795,7 @@ def set_dest_package(self, picking_id, move_line_ids, package_id):
* select_line: lines to package remain
* summary: all lines are put in packages
"""
+ # TODO check if package still exists, call _put_lines_in_package
return self._response()
def summary(self, picking_id):
@@ -813,6 +818,7 @@ def list_package_type(self, picking_id, package_id):
Transitions:
* change_package_type
"""
+ # TODO list product.packaging where product_id is False
return self._response()
def set_package_type(self, picking_id, package_id, package_type_id):
From 8b56d1017445b4b3c0ede8aec2b9a1b69d95d9fc Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 20 Mar 2020 15:07:36 +0100
Subject: [PATCH 156/940] shopfloor: prettify!
---
shopfloor/demo/auth_api_key_demo.xml | 4 +-
shopfloor/demo/shopfloor_menu_demo.xml | 13 +--
.../demo/shopfloor_operation_group_demo.xml | 7 +-
shopfloor/demo/shopfloor_process_demo.xml | 13 +--
shopfloor/demo/shopfloor_profile_demo.xml | 7 +-
shopfloor/demo/stock_picking_type_demo.xml | 85 +++++++++----------
shopfloor/views/menus.xml | 37 ++++++--
shopfloor/views/shopfloor_menu.xml | 15 ++--
shopfloor/views/shopfloor_operation_group.xml | 18 ++--
shopfloor/views/shopfloor_process.xml | 24 +++---
shopfloor/views/shopfloor_profile_views.xml | 28 +++---
shopfloor/views/stock_location.xml | 26 +++---
shopfloor/views/stock_picking_type.xml | 4 +-
13 files changed, 136 insertions(+), 145 deletions(-)
diff --git a/shopfloor/demo/auth_api_key_demo.xml b/shopfloor/demo/auth_api_key_demo.xml
index 800aec5f0b..7858d1d044 100644
--- a/shopfloor/demo/auth_api_key_demo.xml
+++ b/shopfloor/demo/auth_api_key_demo.xml
@@ -1,9 +1,7 @@
-
Demo
-
+
72B044F7AC780DAC
-
diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml
index 84e8e4f0c4..8d324b8762 100644
--- a/shopfloor/demo/shopfloor_menu_demo.xml
+++ b/shopfloor/demo/shopfloor_menu_demo.xml
@@ -1,27 +1,22 @@
-
-
-
-
-
diff --git a/shopfloor/demo/shopfloor_operation_group_demo.xml b/shopfloor/demo/shopfloor_operation_group_demo.xml
index 4f8104efb5..8d549c1648 100644
--- a/shopfloor/demo/shopfloor_operation_group_demo.xml
+++ b/shopfloor/demo/shopfloor_operation_group_demo.xml
@@ -1,8 +1,9 @@
-
-
+
HighBay
-
diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml
index 844ec09df6..e16c9ff92e 100644
--- a/shopfloor/demo/shopfloor_process_demo.xml
+++ b/shopfloor/demo/shopfloor_process_demo.xml
@@ -1,27 +1,22 @@
-
Put-Away Reach Truck
single_pack_putaway
-
+
-
Single Pallet Transfer
single_pack_transfer
-
+
-
Cluster Picking
cluster_picking
-
+
-
Checkout
checkout
-
+
-
diff --git a/shopfloor/demo/shopfloor_profile_demo.xml b/shopfloor/demo/shopfloor_profile_demo.xml
index 5ef9df4179..743890335c 100644
--- a/shopfloor/demo/shopfloor_profile_demo.xml
+++ b/shopfloor/demo/shopfloor_profile_demo.xml
@@ -1,15 +1,12 @@
-
Highbay Truck
-
+
-
Shelf 1
-
+
-
diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml
index 079bf0fb83..1fdf6ae096 100644
--- a/shopfloor/demo/stock_picking_type_demo.xml
+++ b/shopfloor/demo/stock_picking_type_demo.xml
@@ -1,67 +1,62 @@
-
Put-Away Reach Truck
PART
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
internal
-
-
+
+
-
Single Pallet Transfer
SPT
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
internal
-
-
+
+
-
Cluster Picking
CPI
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
internal
-
-
+
+
-
Checkout
CHK
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
internal
-
-
+
+
-
diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml
index 71f19f9f78..6ece0c498f 100644
--- a/shopfloor/views/menus.xml
+++ b/shopfloor/views/menus.xml
@@ -1,8 +1,33 @@
-
+
-
-
-
-
-
+
+
+
+
+
diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml
index 016b872f25..d34c291c34 100644
--- a/shopfloor/views/shopfloor_menu.xml
+++ b/shopfloor/views/shopfloor_menu.xml
@@ -1,29 +1,26 @@
-
+
-
-
-
+
+ Delivery
+ delivery
+
+
diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml
index 1fdf6ae096..ded6d31b77 100644
--- a/shopfloor/demo/stock_picking_type_demo.xml
+++ b/shopfloor/demo/stock_picking_type_demo.xml
@@ -50,6 +50,21 @@
+
+
+
+
+
+ internal
+
+
+
+
+ Delivery
+ DEL
+
+
+
diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py
index 1492df892d..2eafef947c 100644
--- a/shopfloor/models/shopfloor_process.py
+++ b/shopfloor/models/shopfloor_process.py
@@ -19,4 +19,5 @@ def _selection_code(self):
("single_pack_transfer", "Single Pack Transfer"),
("cluster_picking", "Cluster Picking"),
("checkout", "Checkout"),
+ ("delivery", "Delivery"),
]
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 34f9243f4f..8908b1cb7c 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -13,6 +13,7 @@
# process services
from . import checkout
from . import cluster_picking
+from . import delivery
from . import picking_batch
from . import single_pack_putaway
from . import single_pack_transfer
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
new file mode 100644
index 0000000000..48ad6ff668
--- /dev/null
+++ b/shopfloor/services/delivery.py
@@ -0,0 +1,44 @@
+from odoo.addons.component.core import Component
+
+
+class Delivery(Component):
+ """
+ Methods for the Delivery Process
+
+ TODO
+
+ Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
+ """
+
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.delivery"
+ _usage = "delivery"
+ _description = __doc__
+
+
+class ShopfloorDeliveryValidator(Component):
+ """Validators for the Delivery endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.delivery.validator"
+ _usage = "delivery.validator"
+
+
+class ShopfloorDeliveryValidatorResponse(Component):
+ """Validators for the Delivery endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.delivery.validator.response"
+ _usage = "delivery.validator.response"
+
+ _start_state = "start"
+
+ def _states(self):
+ """List of possible next states
+
+ With the schema of the data send to the client to transition
+ to the next state.
+ """
+ return {
+ "start": {},
+ }
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 920e2190e5..9136abf5bb 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -18,3 +18,4 @@
from . import test_checkout_scan_package_action
from . import test_checkout_new_package
from . import test_checkout_summary
+from . import test_delivery_base
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
new file mode 100644
index 0000000000..80c8bc0a91
--- /dev/null
+++ b/shopfloor/tests/test_delivery_base.py
@@ -0,0 +1,25 @@
+from .common import CommonCase
+
+
+class DeliveryCommonCase(CommonCase):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery")
+ cls.process = cls.menu.process_id
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.wh.delivery_steps = "pick_pack_ship"
+ cls.picking_type = cls.process.picking_type_id
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
+ self.service = work.component(usage="delivery")
+
+
+class DeliveryOpenAPICase(DeliveryCommonCase):
+ def test_to_openapi(self):
+ # will raise if it fails to generate the openapi specs
+ self.service.to_openapi()
From 822d8289b4812f0efd4c85380133aca2ed6caa85 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 24 Mar 2020 10:17:19 +0100
Subject: [PATCH 158/940] Replace openapi tests by an auto-discovery test
---
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_app.py | 4 ----
shopfloor/tests/test_checkout_base.py | 6 -----
shopfloor/tests/test_cluster_picking_base.py | 8 -------
shopfloor/tests/test_delivery_base.py | 6 -----
shopfloor/tests/test_menu.py | 4 ----
shopfloor/tests/test_openapi.py | 25 ++++++++++++++++++++
shopfloor/tests/test_picking_batch.py | 4 ----
shopfloor/tests/test_profile.py | 4 ----
shopfloor/tests/test_single_pack_putaway.py | 4 ----
shopfloor/tests/test_single_pack_transfer.py | 4 ----
11 files changed, 26 insertions(+), 44 deletions(-)
create mode 100644 shopfloor/tests/test_openapi.py
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 9136abf5bb..b01b5ef354 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -1,5 +1,6 @@
from . import test_app
from . import test_menu
+from . import test_openapi
from . import test_profile
from . import test_picking_batch
from . import test_single_pack_putaway
diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py
index ff29c176a3..f64bb0f91e 100644
--- a/shopfloor/tests/test_app.py
+++ b/shopfloor/tests/test_app.py
@@ -7,10 +7,6 @@ def setUp(self):
with self.work_on_services() as work:
self.service = work.component(usage="app")
- def test_to_openapi(self):
- # will raise if it fails to generate the openapi specs
- self.service.to_openapi()
-
def test_user_config(self):
"""Request /app/user_config"""
# Simulate the client asking the configuration
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index 0ad543068e..3e55e9ae85 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -64,9 +64,3 @@ def _stock_picking_data(self, picking):
def _move_line_data(self, move_line):
return self.service._data_for_move_line(move_line)
-
-
-class CheckoutOpenAPICase(CheckoutCommonCase):
- def test_to_openapi(self):
- # will raise if it fails to generate the openapi specs
- self.service.to_openapi()
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index f44f3b8a80..3d52804aff 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -102,14 +102,6 @@ def _set_dest_package_and_done(cls, move_lines, dest_package):
)
-class ClusterPickingAPICase(ClusterPickingCommonCase):
- """Base tests for the cluster picking API"""
-
- def test_to_openapi(self):
- # will raise if it fails to generate the openapi specs
- self.service.to_openapi()
-
-
class ClusterPickingLineCommonCase(ClusterPickingCommonCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
index 80c8bc0a91..27d645a703 100644
--- a/shopfloor/tests/test_delivery_base.py
+++ b/shopfloor/tests/test_delivery_base.py
@@ -17,9 +17,3 @@ def setUp(self):
super().setUp()
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="delivery")
-
-
-class DeliveryOpenAPICase(DeliveryCommonCase):
- def test_to_openapi(self):
- # will raise if it fails to generate the openapi specs
- self.service.to_openapi()
diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py
index 685850002b..a1a86ca74d 100644
--- a/shopfloor/tests/test_menu.py
+++ b/shopfloor/tests/test_menu.py
@@ -7,10 +7,6 @@ def setUp(self):
with self.work_on_services() as work:
self.service = work.component(usage="menu")
- def test_to_openapi(self):
- # will raise if it fails to generate the openapi specs
- self.service.to_openapi()
-
def test_menu_search(self):
"""Request /menu/search"""
# Simulate the client searching menus
diff --git a/shopfloor/tests/test_openapi.py b/shopfloor/tests/test_openapi.py
new file mode 100644
index 0000000000..5eff2b327c
--- /dev/null
+++ b/shopfloor/tests/test_openapi.py
@@ -0,0 +1,25 @@
+from .common import CommonCase
+
+
+class TestOpenAPICommonCase(CommonCase):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+
+ # we don't really care about which menu and profile we use
+ # to read the OpenAPI specs
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery")
+ cls.process = cls.menu.process_id
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+
+ def setUp(self):
+ super().setUp()
+
+ def test_openapi(self):
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
+ services = work.many_components()
+ for service in services:
+ if not service._is_rest_service_component:
+ continue
+ # will raise if it fails to generate the openapi specs
+ service.to_openapi()
diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py
index 2be2ab16c8..bf76635a82 100644
--- a/shopfloor/tests/test_picking_batch.py
+++ b/shopfloor/tests/test_picking_batch.py
@@ -44,10 +44,6 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="picking_batch")
- def test_to_openapi(self):
- # will raise if it fails to generate the openapi specs
- self.service.to_openapi()
-
def test_search_empty(self):
"""No batch is available"""
# Simulate the client asking the list of picking batch
diff --git a/shopfloor/tests/test_profile.py b/shopfloor/tests/test_profile.py
index 7a2fc1f4e0..a3eb4489a5 100644
--- a/shopfloor/tests/test_profile.py
+++ b/shopfloor/tests/test_profile.py
@@ -7,10 +7,6 @@ def setUp(self):
with self.work_on_services() as work:
self.service = work.component(usage="profile")
- def test_to_openapi(self):
- # will raise if it fails to generate the openapi specs
- self.service.to_openapi()
-
def test_profile_search(self):
"""Request /profile/search"""
# Simulate the client searching profiles
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 55cbaca4d3..cada111056 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -38,10 +38,6 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="single_pack_putaway")
- def test_to_openapi(self):
- # will raise if it fails to generate the openapi specs
- self.service.to_openapi()
-
def test_start(self):
"""Test the happy path for single pack putaway /start endpoint
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index c4c0a69cb0..c5c49c4261 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -61,10 +61,6 @@ def _simulate_started(self):
package_level.is_done = True
return package_level
- def test_to_openapi(self):
- # will raise if it fails to generate the openapi specs
- self.service.to_openapi()
-
def test_start(self):
"""Test the happy path for single pack transfer /start endpoint
From 431f84dabab52fdab62ff15c9b54fd49c731711e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 24 Mar 2020 14:41:22 +0100
Subject: [PATCH 159/940] delivery: add specifications
---
shopfloor/services/delivery.py | 282 ++++++++++++++++++++++++++++++++-
1 file changed, 281 insertions(+), 1 deletion(-)
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 48ad6ff668..6db8933d8d 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -1,3 +1,4 @@
+from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -5,7 +6,16 @@ class Delivery(Component):
"""
Methods for the Delivery Process
- TODO
+ Deliver the goods by processing the PACK and raw products by delivery order.
+ Last step in the pick/pack/ship steps. (Cluster Picking → Checkout → Delivery)
+
+ Multiple operators could be processing a same delivery order.
+
+ Expected:
+
+ * Existing packages are moved to customer location
+ * Products are moved to customer location as raw products
+ * Bin packed products are placed in new shipping package and shipped to customer
Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
"""
@@ -15,6 +25,151 @@ class Delivery(Component):
_usage = "delivery"
_description = __doc__
+ # TODO we don't know yet if we have to select a destination package or a
+ # destination location
+ def xxx(self, barcode):
+ return self._result()
+
+ # TODO we'll probably to add the selected destination package or location
+ # id in every endpoint parameters and returns
+ def scan_stock_picking(self, barcode):
+ """Scan a stock picking or a package
+
+ When a package is scanned, and has an available move line part of the
+ expected picking type, the package level is directly set to "done" and
+ the stock picking of the line is returned to work on its other lines.
+
+ When a stock picking is scanned and is partially or fully available, it
+ is returned to work on its lines.
+
+ When all the available move lines and package levels of the stock picking
+ are done, the user is directed to the summary screen.
+
+ Transitions:
+ * select_source: error when scanning (stock picking not available, ...)
+ * move_set_done: when a valid package or stock picking has been scanned
+ and the stock picking still have lines / package levels not done
+ * summary: when a valid package or stock picking has been scanned
+ and all the lines of the stock picking are done
+ """
+ return self._response()
+
+ def list_stock_picking(self):
+ """Return the list of stock pickings for the picking type
+
+ It returns only stock picking available or partially available.
+
+ Transitions:
+ * manual_selection: next state to show the list of stock pickings
+ """
+ return self._response()
+
+ def select(self, picking_id):
+ """Select a stock picking from its ID (found using /list_stock_picking)
+
+ It returns only stock picking available or partially available.
+
+ Transitions:
+ * manual_selection: the selected stock picking is no longer valid
+ * move_set_done: the selected stock picking has lines not done
+ * summary: the selected stock picking has all lines done
+ """
+ return self._response()
+
+ def set_qty_done_pack(self, picking_id, package_id):
+ """Set a package to "Done"
+
+ Transitions:
+ * move_set_done: error when setting done, or success but the stock
+ picking has other lines to set done
+ * summary: all the lines of the stock picking are now done
+ """
+ return self._response()
+
+ def set_qty_done_line(self, picking_id, line_id):
+ """Set a move line to "Done"
+
+ Should be called only for lines of raw products, /set_qty_done_pack
+ must be used for lines that move a package.
+
+ Transitions:
+ * move_set_done: error when setting done, or success but the stock
+ picking has other lines to set done
+ * summary: all the lines of the stock picking are now done
+ """
+ return self._response()
+
+ def scan_line(self, picking_id, barcode):
+ """Set a move line or package to "Done" from a barcode
+
+ If the barcode is a package in the picking and is available, it
+ sets it to done.
+
+ If the barcode is a product or a product's packaging, the move lines
+ for this product are set to done. However, if the product is in more
+ than one package, a package barcode is requested, and if the product is
+ tracked by lot/serial, a lot is asked.
+
+ If the barcode is a lot, the mbarcode ove lines for this lot are set to
+ done. However, if the lot is in more than one package, a package
+ barcode is requested.
+
+ NOTE: see scan_line in the Checkout service.
+
+ Transitions:
+ * move_set_done: error when setting done, or success but the stock
+ picking has other lines to set done
+ * summary: all the lines of the stock picking are now done
+ """
+ return self._response()
+
+ def summary(self, picking_id):
+ """Return data for the summary screen
+
+ Transitions:
+ * summary
+ """
+ return self._response()
+
+ def reset_qty_done_pack(self, picking_id, package_id):
+ """Remove "Done" on a package
+
+ Transitions:
+ * summary: return back to this state
+ """
+ return self._response()
+
+ def reset_qty_done_line(self, picking_id, line_id):
+ """Remove "Done" on a move line
+
+ Should be called only for lines of raw products, /set_qty_done_pack
+ must be used for lines that move a package.
+
+ Transitions:
+ * summary: return back to this state
+ """
+ return self._response()
+
+ def done(self, picking_id):
+ """Set the stock picking to done
+
+ Transitions:
+ * summary: error during action
+ * select_source: stock picking was set to done, user can work on the
+ next stock picking
+ """
+ return self._response()
+
+ def back_to_move_set_done(self, picking_id):
+ """Allow to return to the "move_set_done" state with refreshed data
+
+ Transitions:
+ * move_set_done: return to this state when not all lines of the
+ stock picking are done
+ * summary: return back to this state when all lines are done
+ """
+ return self._response()
+
class ShopfloorDeliveryValidator(Component):
"""Validators for the Delivery endpoints"""
@@ -23,6 +178,58 @@ class ShopfloorDeliveryValidator(Component):
_name = "shopfloor.delivery.validator"
_usage = "delivery.validator"
+ # TODO
+ def xxx(self):
+ return {}
+
+ def scan_stock_picking(self):
+ return {"barcode": {"required": True, "type": "string"}}
+
+ def list_stock_picking(self):
+ return {}
+
+ def select(self):
+ return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def set_qty_done_pack(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def set_qty_done_line(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def scan_line(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def summary(self):
+ return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def reset_qty_done_pack(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def reset_qty_done_line(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def done(self):
+ return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def back_to_move_set_done(self):
+ return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
class ShopfloorDeliveryValidatorResponse(Component):
"""Validators for the Delivery endpoints responses"""
@@ -41,4 +248,77 @@ def _states(self):
"""
return {
"start": {},
+ "select_source": self._schema_select_source,
+ "manual_selection": self._schema_selection_list,
+ "move_set_done": self._schema_stock_picking_details,
+ "summary": self._schema_stock_picking_details,
+ "confirm_summary": self._schema_stock_picking_details,
}
+
+ # TODO add the selected dest. package or location in returns (to keep it stateless)
+ @property
+ def _schema_stock_picking_details(self):
+ schema = self.schemas().picking()
+ schema.update(
+ {
+ "move_lines": {
+ "type": "list",
+ "schema": {"type": "dict", "schema": self.schemas().move_line()},
+ }
+ }
+ )
+ return {"picking": {"type": "dict", "schema": schema}}
+
+ @property
+ def _schema_selection_list(self):
+ return {
+ "pickings": {
+ "type": "list",
+ "schema": {"type": "dict", "schema": self.schemas().picking()},
+ }
+ }
+
+ @property
+ def _schema_select_source(self):
+ # TODO we don't know yet if we want to show the dest. location or package
+ return {}
+
+ def xxx(self):
+ return self._response_schema(next_states={"start", "select_source"})
+
+ def scan_stock_picking(self):
+ return self._response_schema(
+ next_states={"select_source", "move_set_done", "summary"}
+ )
+
+ def list_stock_picking(self):
+ return self._response_schema(next_states={"manual_selection"})
+
+ def select(self):
+ return self._response_schema(
+ next_states={"manual_selection", "move_set_done", "summary"}
+ )
+
+ def set_qty_done_pack(self):
+ return self._response_schema(next_states={"move_set_done", "summary"})
+
+ def set_qty_done_line(self):
+ return self._response_schema(next_states={"move_set_done", "summary"})
+
+ def scan_line(self):
+ return self._response_schema(next_states={"move_set_done", "summary"})
+
+ def summary(self):
+ return self._response_schema(next_states={"summary"})
+
+ def reset_qty_done_pack(self):
+ return self._response_schema(next_states={"summary"})
+
+ def reset_qty_done_line(self):
+ return self._response_schema(next_states={"summary"})
+
+ def done(self):
+ return self._response_schema(next_states={"confirm_summary", "start"})
+
+ def back_to_move_set_done(self):
+ return self._response_schema(next_states={"move_set_done", "summary"})
From 4cd2493e00ab23224a7c91cdd65a681ef7aca299 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 25 Mar 2020 12:42:46 +0100
Subject: [PATCH 160/940] cluster picking: refactor /scan_line to prepare for
new requirements
---
shopfloor/services/cluster_picking.py | 175 ++++++++++++--------------
1 file changed, 82 insertions(+), 93 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index e0fd21f207..1931512486 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -113,8 +113,7 @@ def list_batch(self):
def _select_a_picking_batch(self, batches):
# look for in progress + assigned to self first
candidates = batches.filtered(
- lambda batch: batch.state == "in_progress"
- and batch.user_id == self.env.user
+ lambda batch: batch.state == "in_progress" and batch.user_id == self.env.user
)
if candidates:
return candidates[0]
@@ -220,11 +219,7 @@ def _pick_next_line(self, batch, message=None):
next_line = self._next_line_for_pick(batch)
if not next_line:
return self.prepare_unload(batch.id)
- return self._response(
- next_state="start_line",
- data=self._data_move_line(next_line),
- message=message,
- )
+ return self._response_for_start_line(next_line, message=message)
def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func)
@@ -355,85 +350,92 @@ def scan_line(self, move_line_id, barcode):
return self._response(
next_state="start", message=message.unrecoverable_error()
)
- if move_line.package_id.name == barcode:
- return self._response_for_scan_destination(move_line)
- # TODO should search for product packaging too
- elif move_line.product_id.barcode == barcode:
- if move_line.product_id.tracking in ("lot", "serial"):
- return self._response_for_scan_line_product_need_lot(move_line)
- return self._response_for_scan_destination(move_line)
- elif move_line.lot_id.name == barcode:
- return self._response_for_scan_destination(move_line)
- elif move_line.location_id.barcode == barcode:
- # When a user scan a location, we accept only when we knows that
- # they scanned the good thing, so if in the location we have
- # several lots (on a package or a product), several packages,
- # several products or a mix of several products and packages, we
- # ask to scan a more precise barcode.
- location = move_line.location_id
- packages = set()
- products = set()
- lots = set()
- for quant in location.quant_ids:
- if quant.quantity <= 0:
- continue
- if quant.package_id:
- packages.add(quant.package_id)
- else:
- products.add(quant.product_id)
- if quant.lot_id:
- lots.add(quant.lot_id)
-
- if len(lots) > 1:
- return self._response_for_scan_line_several_lots_in_loc(move_line)
- if len(packages | products) > 1:
- if move_line.package_id:
- return self._response_for_scan_line_several_packages_in_loc(
- move_line
- )
- else:
- return self._response_for_scan_line_several_products_in_loc(
- move_line
- )
-
- return self._response_for_scan_destination(move_line)
- return self._response(
- next_state="start_line",
- data=self._data_move_line(move_line),
- message=message.barcode_not_found(),
- )
+ search = self.actions_for("search")
- def _response_for_scan_line_several_lots_in_loc(self, move_line):
- message = self.actions_for("message")
- return self._response(
- next_state="start_line",
- data=self._data_move_line(move_line),
- message=message.several_lots_in_location(move_line.location_id),
- )
+ picking = move_line.picking_id
- def _response_for_scan_line_several_products_in_loc(self, move_line):
- message = self.actions_for("message")
- return self._response(
- next_state="start_line",
- data=self._data_move_line(move_line),
- message=message.several_products_in_location(move_line.location_id),
+ package = search.package_from_scan(barcode)
+ if package and move_line.package_id == package:
+ return self._scan_line_by_package(picking, move_line, package)
+
+ # use the common search method so we search by packaging too
+ product = search.product_from_scan(barcode)
+ if product and move_line.product_id == product:
+ return self._scan_line_by_product(picking, move_line, product)
+
+ lot = search.lot_from_scan(barcode)
+ if lot and move_line.lot_id == lot:
+ return self._scan_line_by_lot(picking, move_line, lot)
+
+ location = search.location_from_scan(barcode)
+ if location and move_line.location_id == location:
+ return self._scan_line_by_location(picking, move_line, location)
+
+ return self._response_for_start_line(
+ move_line, message=message.barcode_not_found()
)
- def _response_for_scan_line_several_packages_in_loc(self, move_line):
+ def _scan_line_by_package(self, picking, move_line, package):
+ return self._response_for_scan_destination(move_line)
+
+ def _scan_line_by_product(self, picking, move_line, product):
message = self.actions_for("message")
- return self._response(
- next_state="start_line",
- data=self._data_move_line(move_line),
- message=message.several_packs_in_location(move_line.location_id),
- )
+ if move_line.product_id.tracking in ("lot", "serial"):
+ return self._response_for_start_line(
+ move_line, message=message.scan_lot_on_product_tracked_by_lot()
+ )
+ return self._response_for_scan_destination(move_line)
- def _response_for_scan_line_product_need_lot(self, move_line):
+ def _scan_line_by_lot(self, picking, move_line, lot):
+ return self._response_for_scan_destination(move_line)
+
+ def _scan_line_by_location(self, picking, move_line, location):
message = self.actions_for("message")
+ # When a user scan a location, we accept only when we knows that
+ # they scanned the good thing, so if in the location we have
+ # several lots (on a package or a product), several packages,
+ # several products or a mix of several products and packages, we
+ # ask to scan a more precise barcode.
+ location = move_line.location_id
+ packages = set()
+ products = set()
+ lots = set()
+ for quant in location.quant_ids:
+ if quant.quantity <= 0:
+ continue
+ if quant.package_id:
+ packages.add(quant.package_id)
+ else:
+ products.add(quant.product_id)
+ if quant.lot_id:
+ lots.add(quant.lot_id)
+
+ if len(lots) > 1:
+ return self._response_for_start_line(
+ move_line,
+ message=message.several_lots_in_location(move_line.location_id),
+ )
+
+ if len(packages | products) > 1:
+ if move_line.package_id:
+ return self._response_for_start_line(
+ move_line,
+ message=message.several_packs_in_location(move_line.location_id),
+ )
+ else:
+ return self._response_for_start_line(
+ move_line,
+ message=message.several_products_in_location(move_line.location_id),
+ )
+
+ return self._response_for_scan_destination(move_line)
+
+ def _response_for_start_line(self, move_line, message=None):
return self._response(
next_state="start_line",
data=self._data_move_line(move_line),
- message=message.scan_lot_on_product_tracked_by_lot(),
+ message=message,
)
def _response_for_scan_destination(self, move_line, message=None):
@@ -817,9 +819,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
return self._response_for_unload_all(
batch, message=message.no_location_found()
)
- if not scanned_location.is_sublocation_of(
- picking_type.default_location_dest_id
- ):
+ if not scanned_location.is_sublocation_of(picking_type.default_location_dest_id):
return self._response_for_unload_all(
batch, message=message.dest_location_not_allowed()
)
@@ -852,9 +852,7 @@ def _unload_end(self, batch):
next_line = self._next_line_for_pick(batch)
if next_line:
- return self._response(
- next_state="start_line", data=self._data_move_line(next_line)
- )
+ return self._response_for_start_line(next_line)
else:
# TODO add tests for this (for instance a picking is not 'done'
# because a move was unassigned, we want to validate the batch to
@@ -866,10 +864,7 @@ def _unload_end(self, batch):
def _response_batch_complete(self):
return self._response(
next_state="start",
- message={
- "message_type": "success",
- "message": _("Batch Transfer complete"),
- },
+ message={"message_type": "success", "message": _("Batch Transfer complete")},
)
def unload_split(self, picking_batch_id):
@@ -992,9 +987,7 @@ def unload_scan_destination(
return self._response_for_unload_set_destination(
batch, package, message=message.no_location_found()
)
- if not scanned_location.is_sublocation_of(
- picking_type.default_location_dest_id
- ):
+ if not scanned_location.is_sublocation_of(picking_type.default_location_dest_id):
return self._response_for_unload_set_destination(
batch, package, message=message.dest_location_not_allowed()
)
@@ -1301,11 +1294,7 @@ def _schema_for_batch_details(self):
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"move_line_count": {"required": True, "type": "integer"},
- "weight": {
- "type": "float",
- "nullable": False,
- "required": True,
- },
+ "weight": {"type": "float", "nullable": False, "required": True},
"origin": {
"type": "string",
"nullable": False,
From 49d73c51e5d8642a13823842c0064d679d75dfdd Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 25 Mar 2020 14:00:48 +0100
Subject: [PATCH 161/940] cluster picking: prevent scanning product/lot with
packages
When a product or a lot is scanned and the stock picking may have
more than one package for the product or the lot, ask to scan the
package.
---
shopfloor/services/cluster_picking.py | 50 ++++++++++--
shopfloor/tests/test_cluster_picking_scan.py | 83 ++++++++++++++++++++
2 files changed, 128 insertions(+), 5 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 1931512486..a81634f37d 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -113,7 +113,8 @@ def list_batch(self):
def _select_a_picking_batch(self, batches):
# look for in progress + assigned to self first
candidates = batches.filtered(
- lambda batch: batch.state == "in_progress" and batch.user_id == self.env.user
+ lambda batch: batch.state == "in_progress"
+ and batch.user_id == self.env.user
)
if candidates:
return candidates[0]
@@ -385,9 +386,37 @@ def _scan_line_by_product(self, picking, move_line, product):
return self._response_for_start_line(
move_line, message=message.scan_lot_on_product_tracked_by_lot()
)
+
+ # if we scanned a product and it's part of several packages, we can't be
+ # sure the user scanned the correct one, in such case, ask to scan a package
+ other_product_lines = picking.move_line_ids.filtered(
+ lambda l: l.product_id == product
+ )
+ packages = other_product_lines.mapped("package_id")
+ # Do not use mapped here: we want to see if we have more than one package,
+ # but also if we have one product as a package and the same product as
+ # a unit in another line. In both cases, we want the user to scan the
+ # package.
+ if packages and len({l.package_id for l in other_product_lines}) > 1:
+ return self._response_for_start_line(
+ move_line, message=message.product_multiple_packages_scan_package()
+ )
return self._response_for_scan_destination(move_line)
def _scan_line_by_lot(self, picking, move_line, lot):
+ message = self.actions_for("message")
+ # if we scanned a lot and it's part of several packages, we can't be
+ # sure the user scanned the correct one, in such case, ask to scan a package
+ other_lot_lines = picking.move_line_ids.filtered(lambda l: l.lot_id == lot)
+ packages = other_lot_lines.mapped("package_id")
+ # Do not use mapped here: we want to see if we have more than one
+ # package, but also if we have one lot as a package and the same lot as
+ # a unit in another line. In both cases, we want the user to scan the
+ # package.
+ if packages and len({l.package_id for l in other_lot_lines}) > 1:
+ return self._response_for_start_line(
+ move_line, message=message.lot_multiple_packages_scan_package()
+ )
return self._response_for_scan_destination(move_line)
def _scan_line_by_location(self, picking, move_line, location):
@@ -819,7 +848,9 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
return self._response_for_unload_all(
batch, message=message.no_location_found()
)
- if not scanned_location.is_sublocation_of(picking_type.default_location_dest_id):
+ if not scanned_location.is_sublocation_of(
+ picking_type.default_location_dest_id
+ ):
return self._response_for_unload_all(
batch, message=message.dest_location_not_allowed()
)
@@ -864,7 +895,10 @@ def _unload_end(self, batch):
def _response_batch_complete(self):
return self._response(
next_state="start",
- message={"message_type": "success", "message": _("Batch Transfer complete")},
+ message={
+ "message_type": "success",
+ "message": _("Batch Transfer complete"),
+ },
)
def unload_split(self, picking_batch_id):
@@ -987,7 +1021,9 @@ def unload_scan_destination(
return self._response_for_unload_set_destination(
batch, package, message=message.no_location_found()
)
- if not scanned_location.is_sublocation_of(picking_type.default_location_dest_id):
+ if not scanned_location.is_sublocation_of(
+ picking_type.default_location_dest_id
+ ):
return self._response_for_unload_set_destination(
batch, package, message=message.dest_location_not_allowed()
)
@@ -1294,7 +1330,11 @@ def _schema_for_batch_details(self):
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"move_line_count": {"required": True, "type": "integer"},
- "weight": {"type": "float", "nullable": False, "required": True},
+ "weight": {
+ "type": "float",
+ "nullable": False,
+ "required": True,
+ },
"origin": {
"type": "string",
"nullable": False,
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 93682b07ff..5192134661 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -73,6 +73,89 @@ def test_scan_line_error_product_tracked(self):
},
)
+ def test_scan_line_product_error_several_packages(self):
+ """When we scan a product which is in more than one package, error"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ # create a second move line for the same product in a different
+ # package
+ move = line.move_id.copy()
+ self._fill_stock_for_moves(move, in_package=True)
+ move._action_confirm(merge=False)
+ move._action_assign()
+
+ self._scan_line_error(
+ line,
+ move.product_id.barcode,
+ {
+ "message_type": "warning",
+ "message": "This product is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
+
+ def test_scan_line_product_error_in_one_package_and_unit(self):
+ """When we scan a product which is in a package and as raw, error"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ # create a second move line for the same product in a different
+ # package
+ move = line.move_id.copy()
+ self._fill_stock_for_moves(move)
+ move._action_confirm(merge=False)
+ move._action_assign()
+
+ self._scan_line_error(
+ line,
+ move.product_id.barcode,
+ {
+ "message_type": "warning",
+ "message": "This product is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
+
+ def test_scan_line_lot_error_several_packages(self):
+ """When we scan a lot which is in more than one package, error"""
+ self._simulate_batch_selected(self.batch, in_package=True, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ # create a second move line for the same product in a different
+ # package
+ move = line.move_id.copy()
+ self._fill_stock_for_moves(move, in_lot=line.lot_id)
+ move._action_confirm(merge=False)
+ move._action_assign()
+
+ self._scan_line_error(
+ line,
+ line.lot_id.name,
+ {
+ "message_type": "warning",
+ "message": "This lot is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
+
+ def test_scan_line_lot_error_in_one_package_and_unit(self):
+ """When we scan a lot which is in a package and as raw, error"""
+ self._simulate_batch_selected(self.batch, in_package=True, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ # create a second move line for the same product in a different
+ # package
+ move = line.move_id.copy()
+ self._fill_stock_for_moves(move, in_lot=line.lot_id)
+ move._action_confirm(merge=False)
+ move._action_assign()
+ self._scan_line_error(
+ line,
+ line.lot_id.name,
+ {
+ "message_type": "warning",
+ "message": "This lot is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
+
def test_scan_line_location_ok_single_package(self):
"""Scan to check if user scans a correct location for current line
From 4e6b5c1af87f3fda4f769bc070953eaeee1946d3 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 25 Mar 2020 14:35:09 +0100
Subject: [PATCH 162/940] checkout: fix flaky test
---
shopfloor/services/checkout.py | 2 +-
shopfloor/tests/test_checkout_set_qty.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 2a72de2dd4..ab14dd3294 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -296,7 +296,7 @@ def _response_for_select_package(self, lines, message=None):
next_state="select_package",
data={
"selected_move_lines": [
- self._data_for_move_line(line) for line in lines
+ self._data_for_move_line(line) for line in lines.sorted()
],
"picking": self._data_picking_base(picking),
},
diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py
index e2df7952a3..d6a91c9028 100644
--- a/shopfloor/tests/test_checkout_set_qty.py
+++ b/shopfloor/tests/test_checkout_set_qty.py
@@ -147,7 +147,7 @@ def test_set_custom_qty_ok(self):
{line_to_change: new_qty, line_keep_qty: line_keep_qty.product_uom_qty},
)
- def test_set_custem_qty_not_found(self):
+ def test_set_custom_qty_not_found(self):
selected_lines = self.moves_pack1.move_line_ids
response = self.service.dispatch(
"set_custom_qty",
From bcb0395f0c5e9f671e4a26de379dc3c0e53ab60c Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 26 Mar 2020 08:07:28 +0100
Subject: [PATCH 163/940] checkout: implement /list_dest_package
---
shopfloor/services/checkout.py | 62 +++++++++++++--
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_checkout_base.py | 3 +
shopfloor/tests/test_checkout_list_package.py | 78 +++++++++++++++++++
4 files changed, 137 insertions(+), 7 deletions(-)
create mode 100644 shopfloor/tests/test_checkout_list_package.py
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index ab14dd3294..3f5cd577d5 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -749,7 +749,21 @@ def new_package(self, picking_id, selected_line_ids):
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
return self._create_and_assign_new_packaging(picking, selected_lines)
- def list_dest_package(self, picking_id, move_line_ids):
+ def _data_package(self, picking, package):
+ line_count = len(
+ picking.move_line_ids.filtered(lambda l: l.package_id == package)
+ )
+ return {
+ "id": package.id,
+ "name": package.name,
+ # TODO
+ "weight": 0,
+ "line_count": line_count,
+ # TODO
+ "package_type_name": "",
+ }
+
+ def list_dest_package(self, picking_id, selected_line_ids):
"""Return a list of packages the user can select for the lines
Only valid packages must be proposed. Look at ``scan_dest_package``
@@ -757,12 +771,44 @@ def list_dest_package(self, picking_id, move_line_ids):
Transitions:
* select_dest_package: selection screen
+ * select_package: when no package is available
"""
- # TODO get list of all result_package_id | package_id of picking's move
- # lines, return to 'select_dest_package' with the list
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+
+ lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
+
+ packages = picking.mapped("move_line_ids.package_id") | picking.mapped(
+ "move_line_ids.result_package_id"
+ )
+ if not packages:
+ return self._response_for_select_package(
+ lines,
+ message={
+ "message_type": "warning",
+ "message": _("No valid package to select."),
+ },
+ )
+ picking_data = self._data_picking_base(picking)
+ packages_data = [
+ self._data_package(picking, package) for package in packages.sorted()
+ ]
- def scan_dest_package(self, picking_id, move_line_ids, barcode):
+ return self._response(
+ next_state="select_dest_package",
+ data={
+ "picking": picking_data,
+ "packages": packages_data,
+ "selected_move_lines": [
+ # TODO factorize
+ self._data_for_move_line(line)
+ for line in lines.sorted()
+ ],
+ },
+ )
+
+ def scan_dest_package(self, picking_id, selected_line_ids, barcode):
"""Scan destination package for lines
Set the destination package on the selected lines with a `qty_done` if
@@ -783,7 +829,7 @@ def scan_dest_package(self, picking_id, move_line_ids, barcode):
# _put_lines_in_package
return self._response()
- def set_dest_package(self, picking_id, move_line_ids, package_id):
+ def set_dest_package(self, picking_id, selected_line_ids, package_id):
"""Set destination package for lines from a package id
Used by the list obtained from ``list_dest_package``.
@@ -1125,7 +1171,9 @@ def new_package(self):
return self._response_schema(next_states={"select_line"})
def list_dest_package(self):
- return self._response_schema(next_states={"select_dest_package"})
+ return self._response_schema(
+ next_states={"select_dest_package", "select_package"}
+ )
def scan_dest_package(self):
return self._response_schema(
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index b01b5ef354..709fa5cd76 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -18,5 +18,6 @@
from . import test_checkout_set_qty
from . import test_checkout_scan_package_action
from . import test_checkout_new_package
+from . import test_checkout_list_package
from . import test_checkout_summary
from . import test_delivery_base
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index 3e55e9ae85..8053ee6ace 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -64,3 +64,6 @@ def _stock_picking_data(self, picking):
def _move_line_data(self, move_line):
return self.service._data_for_move_line(move_line)
+
+ def _package_data(self, picking, package):
+ return self.service._data_package(picking, package)
diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py
new file mode 100644
index 0000000000..2b70308bce
--- /dev/null
+++ b/shopfloor/tests/test_checkout_list_package.py
@@ -0,0 +1,78 @@
+from .test_checkout_base import CheckoutCommonCase
+from .test_checkout_select_package_base import CheckoutSelectPackageMixin
+
+
+class CheckoutListDestPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
+ def test_list_dest_package_ok(self):
+ picking = self._create_picking(
+ lines=[
+ (self.product_a, 10),
+ (self.product_b, 10),
+ (self.product_c, 10),
+ (self.product_d, 10),
+ ]
+ )
+ self._fill_stock_for_moves(picking.move_lines[:2], in_package=True)
+ self._fill_stock_for_moves(picking.move_lines[2], in_package=True)
+ self._fill_stock_for_moves(picking.move_lines[3], in_package=True)
+ picking.action_assign()
+ new_package = self.env["stock.quant.package"].create({})
+ picking.move_lines[1].move_line_ids.result_package_id = new_package
+
+ packages = picking.mapped("move_line_ids.package_id") | new_package
+
+ response = self.service.dispatch(
+ "list_dest_package",
+ params={
+ "picking_id": picking.id,
+ "selected_line_ids": picking.move_line_ids.ids,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="select_dest_package",
+ data={
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "note": "",
+ "origin": "",
+ "line_count": len(picking.move_line_ids),
+ "partner": {"id": self.customer.id, "name": self.customer.name},
+ },
+ "packages": [
+ self._package_data(picking, package) for package in packages
+ ],
+ "selected_move_lines": [
+ self._move_line_data(ml) for ml in picking.move_line_ids.sorted()
+ ],
+ },
+ )
+
+ def test_list_dest_package_error_no_package(self):
+ picking = self._create_picking(
+ lines=[
+ (self.product_a, 10),
+ (self.product_b, 10),
+ (self.product_c, 10),
+ (self.product_d, 10),
+ ]
+ )
+ self._fill_stock_for_moves(picking.move_lines)
+ picking.action_assign()
+ self.assertEqual(picking.state, "assigned")
+ response = self.service.dispatch(
+ "list_dest_package",
+ params={
+ "picking_id": picking.id,
+ "selected_line_ids": picking.move_line_ids.ids,
+ },
+ )
+ self._assert_selected_response(
+ response,
+ picking.move_line_ids,
+ message={
+ "message_type": "warning",
+ "message": "No valid package to select.",
+ },
+ )
From 1c76f696d875e08c31caf2600204f0f7ce5630d7 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 26 Mar 2020 15:22:14 +0100
Subject: [PATCH 164/940] checkout: implement /set_dest_package and
/scan_dest_package
---
shopfloor/services/checkout.py | 57 +++--
shopfloor/tests/test_checkout_list_package.py | 208 ++++++++++++++++--
2 files changed, 230 insertions(+), 35 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 3f5cd577d5..108e96df7c 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -606,6 +606,10 @@ def _switch_line_qty_done(self, picking, selected_lines, switch_lines):
def _filter_lines_to_pack(move_line):
return move_line.qty_done > 0 and not move_line.shopfloor_checkout_packed
+ def _is_package_allowed(self, picking, package):
+ existing_packages = picking.mapped("move_line_ids.result_package_id")
+ return package in existing_packages
+
def _put_lines_in_package(self, picking, selected_lines, package):
"""Put the current selected lines with a qty_done in a package
@@ -615,8 +619,7 @@ def _put_lines_in_package(self, picking, selected_lines, package):
since Odoo set the value of the result package to the source package by
default, it works by default.
"""
- existing_packages = picking.mapped("move_line_ids.result_package_id")
- if package not in existing_packages:
+ if not self._is_package_allowed(picking, package):
return self._response_for_select_package(
selected_lines,
message={
@@ -778,13 +781,15 @@ def list_dest_package(self, picking_id, selected_line_ids):
return self._response_stock_picking_does_not_exist()
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
+ return self._response_for_select_dest_package(picking, lines)
+ def _response_for_select_dest_package(self, picking, move_lines, message=None):
packages = picking.mapped("move_line_ids.package_id") | picking.mapped(
"move_line_ids.result_package_id"
)
if not packages:
return self._response_for_select_package(
- lines,
+ move_lines,
message={
"message_type": "warning",
"message": _("No valid package to select."),
@@ -794,20 +799,34 @@ def list_dest_package(self, picking_id, selected_line_ids):
packages_data = [
self._data_package(picking, package) for package in packages.sorted()
]
-
return self._response(
next_state="select_dest_package",
data={
"picking": picking_data,
"packages": packages_data,
"selected_move_lines": [
- # TODO factorize
- self._data_for_move_line(line)
- for line in lines.sorted()
+ self._data_for_move_line(line) for line in move_lines.sorted()
],
},
+ message=message,
)
+ def _set_dest_package_from_selection(self, picking, selected_lines, package):
+ if not package:
+ return self._response_for_select_dest_package(picking, selected_lines)
+ if not self._is_package_allowed(picking, package):
+ return self._response_for_select_dest_package(
+ picking,
+ selected_lines,
+ message={
+ "message_type": "error",
+ "message": _("Not a valid destination package").format(
+ package.name
+ ),
+ },
+ )
+ return self._put_lines_in_allowed_package(picking, selected_lines, package)
+
def scan_dest_package(self, picking_id, selected_line_ids, barcode):
"""Scan destination package for lines
@@ -821,13 +840,17 @@ def scan_dest_package(self, picking_id, selected_line_ids, barcode):
package on lines.
Transitions:
- * select_package: error when scanning package
+ * select_dest_package: error when scanning package
* select_line: lines to package remain
* summary: all lines are put in packages
"""
- # TODO search for stock.quant.package with barcode, if found, call
- # _put_lines_in_package
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+ lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
+ search = self.actions_for("search")
+ package = search.package_from_scan(barcode)
+ return self._set_dest_package_from_selection(picking, lines, package)
def set_dest_package(self, picking_id, selected_line_ids, package_id):
"""Set destination package for lines from a package id
@@ -837,12 +860,16 @@ def set_dest_package(self, picking_id, selected_line_ids, package_id):
The validity is the same as ``scan_dest_package``.
Transitions:
- * select_dest_package: error when scanning package
+ * select_dest_package: error when selecting package
* select_line: lines to package remain
* summary: all lines are put in packages
"""
- # TODO check if package still exists, call _put_lines_in_package
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+ lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
+ package = self.env["stock.quant.package"].browse(package_id).exists()
+ return self._set_dest_package_from_selection(picking, lines, package)
def summary(self, picking_id):
"""Return information for the summary screen
@@ -1177,7 +1204,7 @@ def list_dest_package(self):
def scan_dest_package(self):
return self._response_schema(
- next_states={"select_package", "select_line", "summary"}
+ next_states={"select_dest_package", "select_line", "summary"}
)
def set_dest_package(self):
diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py
index 2b70308bce..dcbd7a59c9 100644
--- a/shopfloor/tests/test_checkout_list_package.py
+++ b/shopfloor/tests/test_checkout_list_package.py
@@ -2,7 +2,36 @@
from .test_checkout_select_package_base import CheckoutSelectPackageMixin
-class CheckoutListDestPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
+class SelectDestPackageMixin:
+ def _assert_response_select_dest_package(
+ self, response, picking, selected_lines, packages, message=None
+ ):
+ self.assert_response(
+ response,
+ next_state="select_dest_package",
+ data={
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "note": "",
+ "origin": "",
+ "line_count": len(picking.move_line_ids),
+ "partner": {"id": self.customer.id, "name": self.customer.name},
+ },
+ "packages": [
+ self._package_data(picking, package) for package in packages
+ ],
+ "selected_move_lines": [
+ self._move_line_data(ml) for ml in selected_lines.sorted()
+ ],
+ },
+ message=message,
+ )
+
+
+class CheckoutListDestPackageCase(
+ CheckoutCommonCase, CheckoutSelectPackageMixin, SelectDestPackageMixin
+):
def test_list_dest_package_ok(self):
picking = self._create_picking(
lines=[
@@ -28,25 +57,8 @@ def test_list_dest_package_ok(self):
"selected_line_ids": picking.move_line_ids.ids,
},
)
- self.assert_response(
- response,
- next_state="select_dest_package",
- data={
- "picking": {
- "id": picking.id,
- "name": picking.name,
- "note": "",
- "origin": "",
- "line_count": len(picking.move_line_ids),
- "partner": {"id": self.customer.id, "name": self.customer.name},
- },
- "packages": [
- self._package_data(picking, package) for package in packages
- ],
- "selected_move_lines": [
- self._move_line_data(ml) for ml in picking.move_line_ids.sorted()
- ],
- },
+ self._assert_response_select_dest_package(
+ response, picking, picking.move_line_ids, packages
)
def test_list_dest_package_error_no_package(self):
@@ -76,3 +88,159 @@ def test_list_dest_package_error_no_package(self):
"message": "No valid package to select.",
},
)
+
+
+class CheckoutScanSetDestPackageCase(CheckoutCommonCase, SelectDestPackageMixin):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ picking = cls._create_picking(
+ lines=[
+ (cls.product_a, 10),
+ (cls.product_b, 10),
+ (cls.product_c, 10),
+ (cls.product_d, 10),
+ ]
+ )
+ pack1_moves = picking.move_lines[:3]
+ pack2_moves = picking.move_lines[3:]
+ # put in 2 packs, for this test, we'll work on pack1
+ cls._fill_stock_for_moves(pack1_moves, in_package=True)
+ cls._fill_stock_for_moves(pack2_moves, in_package=True)
+ picking.action_assign()
+
+ cls.selected_lines = pack1_moves.move_line_ids
+ cls.pack1 = pack1_moves.move_line_ids.package_id
+ cls.allowed_packages = picking.mapped(
+ "move_line_ids.package_id"
+ ) | picking.mapped("move_line_ids.result_package_id")
+
+ cls.move_line1, cls.move_line2, cls.move_line3 = cls.selected_lines
+ # We'll put only product A and B in the destination package
+ cls.move_line1.qty_done = cls.move_line1.product_uom_qty
+ cls.move_line2.qty_done = cls.move_line2.product_uom_qty
+ cls.move_line3.qty_done = 0
+
+ cls.picking = picking
+ cls.package = cls.move_line1.result_package_id
+
+ def _assert_package_set(self, response):
+ self.assertRecordValues(
+ self.move_line1 + self.move_line2 + self.move_line3,
+ [
+ {
+ "result_package_id": self.package.id,
+ "shopfloor_checkout_packed": True,
+ },
+ {
+ "result_package_id": self.package.id,
+ "shopfloor_checkout_packed": True,
+ },
+ # qty_done was zero so we don't set it as packed
+ {
+ "result_package_id": self.pack1.id,
+ "shopfloor_checkout_packed": False,
+ },
+ ],
+ )
+ self.assert_response(
+ response,
+ # go pack to the screen to select lines to put in packages
+ next_state="select_line",
+ data={"picking": self._stock_picking_data(self.picking)},
+ message={
+ "message_type": "info",
+ "message": "Product(s) packed in {}".format(self.pack1.name),
+ },
+ )
+
+ def test_scan_dest_package_ok(self):
+ response = self.service.dispatch(
+ "scan_dest_package",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": self.selected_lines.ids,
+ # we keep the goods in the same package, so we scan the source package
+ "barcode": self.package.name,
+ },
+ )
+ self._assert_package_set(response)
+
+ def test_scan_dest_package_error_not_found(self):
+ response = self.service.dispatch(
+ "scan_dest_package",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": self.selected_lines.ids,
+ "barcode": "NO BARCODE",
+ },
+ )
+ self._assert_response_select_dest_package(
+ response, self.picking, self.selected_lines, self.allowed_packages
+ )
+
+ def test_scan_dest_package_error_not_allowed(self):
+ package = self.env["stock.quant.package"].create({})
+ response = self.service.dispatch(
+ "scan_dest_package",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": self.selected_lines.ids,
+ "barcode": package.name,
+ },
+ )
+ self._assert_response_select_dest_package(
+ response,
+ self.picking,
+ self.selected_lines,
+ self.allowed_packages,
+ message={
+ "message_type": "error",
+ "message": "Not a valid destination package",
+ },
+ )
+
+ def test_set_dest_package_ok(self):
+ response = self.service.dispatch(
+ "set_dest_package",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": self.selected_lines.ids,
+ "package_id": self.package.id,
+ },
+ )
+ self._assert_package_set(response)
+
+ def test_set_dest_package_error_not_found(self):
+ response = self.service.dispatch(
+ "set_dest_package",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": self.selected_lines.ids,
+ "package_id": 0,
+ },
+ )
+ self._assert_response_select_dest_package(
+ response, self.picking, self.selected_lines, self.allowed_packages
+ )
+
+ def test_set_dest_package_error_not_allowed(self):
+ package = self.env["stock.quant.package"].create({})
+ response = self.service.dispatch(
+ "set_dest_package",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": self.selected_lines.ids,
+ "package_id": package.id,
+ },
+ )
+ self._assert_response_select_dest_package(
+ response,
+ self.picking,
+ self.selected_lines,
+ self.allowed_packages,
+ message={
+ "message_type": "error",
+ "message": "Not a valid destination package",
+ },
+ )
From addcf8c3853eb26fb72657e3b8c20b28588faf36 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 27 Mar 2020 11:08:05 +0100
Subject: [PATCH 165/940] checkout: implement /list_packaging
Note: remove dependencies on stock_storage_type_putaway_strategy (and
its sub-dependencies), because it is in fact not required: the mention
of "package type" in the functional specs means "packaging", not
"packaging type".
---
shopfloor/__manifest__.py | 6 --
shopfloor/services/checkout.py | 96 ++++++++++++-------
shopfloor/services/schema.py | 4 +-
shopfloor/tests/__init__.py | 1 +
.../tests/test_checkout_change_packaging.py | 96 +++++++++++++++++++
.../test_checkout_scan_package_action.py | 7 +-
6 files changed, 160 insertions(+), 50 deletions(-)
create mode 100644 shopfloor/tests/test_checkout_change_packaging.py
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index cc03632c6a..edba0364fa 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -18,12 +18,6 @@
"auth_api_key",
# https://github.com/OCA/stock-logistics-warehouse/pull/808
"stock_picking_completion_info",
- # https://github.com/OCA/wms/pull/12
- "stock_storage_type",
- # https://github.com/OCA/wms/pull/13
- "stock_storage_type_putaway_strategy",
- # https://github.com/OCA/stock-logistics-warehouse/pull/855
- "stock_location_children",
# https://github.com/OCA/stock-logistics-workflow/pull/608
"stock_quant_package_dimension",
# https://github.com/OCA/stock-logistics-workflow/pull/607
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 108e96df7c..8f31b97385 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -105,14 +105,14 @@ def _select_picking(self, picking, state_for_error):
def _response_for_selected_stock_picking(self, picking, message=None):
if all(line.shopfloor_checkout_packed for line in picking.move_line_ids):
- return self._response_for_all_lines_packed(picking, message=message)
+ return self._response_for_summary(picking, message=message)
return self._response(
next_state="select_line",
data={"picking": self._data_for_stock_picking(picking)},
message=message,
)
- def _response_for_all_lines_packed(self, picking, message=None):
+ def _response_for_summary(self, picking, message=None):
return self._response(
next_state="summary",
data={"picking": self._data_for_stock_picking(picking)},
@@ -184,8 +184,8 @@ def _data_for_move_line(self, move_line):
"weight": 0,
# TODO
"line_count": 0,
- "package_type_name": (
- move_line.package_id.package_storage_type_id.name or ""
+ "packaging_name": (
+ move_line.package_id.product_packaging_id.name or ""
),
}
if move_line.package_id
@@ -197,8 +197,8 @@ def _data_for_move_line(self, move_line):
"weight": 0,
# TODO
"line_count": 0,
- "package_type_name": (
- move_line.result_package_id.package_storage_type_id.name or ""
+ "packaging_name": (
+ move_line.result_package_id.product_packaging_id.name or ""
),
}
if move_line.result_package_id
@@ -646,10 +646,8 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package):
)
def _prepare_vals_package_from_packaging(self, packaging):
- package_type = packaging.package_storage_type_id
return {
- "package_storage_type_id": package_type.id,
- "packaging_id": packaging.id,
+ "product_packaging_id": packaging.id,
"lngth": packaging.lngth,
"width": packaging.width,
"height": packaging.height,
@@ -762,8 +760,7 @@ def _data_package(self, picking, package):
# TODO
"weight": 0,
"line_count": line_count,
- # TODO
- "package_type_name": "",
+ "packaging_name": package.product_packaging_id.name or "",
}
def list_dest_package(self, picking_id, selected_line_ids):
@@ -880,25 +877,55 @@ def summary(self, picking_id):
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
return self._response_stock_picking_does_not_exist()
- return self._response_for_all_lines_packed(picking)
+ return self._response_for_summary(picking)
+
+ def _data_packaging(self, packaging):
+ return {"id": packaging.id, "name": packaging.name}
- def list_package_type(self, picking_id, package_id):
+ def _get_allowed_packaging(self):
+ return self.env["product.packaging"].search([("product_id", "=", False)])
+
+ def list_packaging(self, picking_id, package_id):
"""List the available package types for a package
- For a package, we can change the package type. The available
- package types are the ones with no product.
+ For a package, we can change the packaging. The available
+ packaging are the ones with no product.
Transitions:
- * change_package_type
+ * change_packaging
+ * summary: if the package_id no longer exists
"""
- # TODO list product.packaging where product_id is False
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+
+ package = self.env["stock.quant.package"].browse(package_id).exists()
+ packaging_list = self._get_allowed_packaging()
+ return self._response_for_change_packaging(picking, package, packaging_list)
+
+ def _response_for_change_packaging(self, picking, package, packaging_list):
+ message = self.actions_for("message")
+ if not package:
+ return self._response_for_summary(
+ picking, message=message.record_not_found()
+ )
+ return self._response(
+ next_state="change_packaging",
+ data={
+ "picking": self._data_picking_base(picking),
+ "package": self._data_package(picking, package),
+ "packagings": [
+ self._data_packaging(packaging)
+ for packaging in packaging_list.sorted()
+ ],
+ },
+ )
- def set_package_type(self, picking_id, package_id, package_type_id):
+ def set_packaging(self, picking_id, package_id, packaging_id):
"""Set a package type on a package
Transitions:
- * change_package_type: in case of error
+ * change_packaging: in case of error
* summary
"""
return self._response()
@@ -1048,17 +1075,17 @@ def set_dest_package(self):
def summary(self):
return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}}
- def list_package_type(self):
+ def list_packaging(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
"package_id": {"coerce": to_int, "required": True, "type": "integer"},
}
- def set_package_type(self):
+ def set_packaging(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
"package_id": {"coerce": to_int, "required": True, "type": "integer"},
- "package_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "packaging_id": {"coerce": to_int, "required": True, "type": "integer"},
}
def remove_package(self):
@@ -1097,7 +1124,7 @@ def _states(self):
"change_quantity": self._schema_selected_lines,
"select_dest_package": self._schema_select_package,
"summary": self._schema_stock_picking_details,
- "change_package_type": self._schema_select_package_type,
+ "change_packaging": self._schema_select_packaging,
"confirm_done": self._schema_stock_picking_details,
}
@@ -1138,17 +1165,14 @@ def _schema_select_package(self):
}
@property
- def _schema_select_package_type(self):
+ def _schema_select_packaging(self):
return {
- "selected_move_lines": {
- "type": "list",
- "schema": {"type": "dict", "schema": self.schemas().move_line()},
- },
- "package_types": {
+ "picking": {"type": "dict", "schema": self.schemas().picking()},
+ "package": {"type": "dict", "schema": self.schemas().package()},
+ "packagings": {
"type": "list",
- "schema": {"type": "dict", "schema": self.schemas().package_type()},
+ "schema": {"type": "dict", "schema": self.schemas().packaging()},
},
- "picking": {"type": "dict", "schema": self.schemas().picking()},
}
@property
@@ -1215,11 +1239,11 @@ def set_dest_package(self):
def summary(self):
return self._response_schema(next_states={"summary"})
- def list_package_type(self):
- return self._response_schema(next_states={"change_package_type"})
+ def list_packaging(self):
+ return self._response_schema(next_states={"change_packaging", "summary"})
- def set_package_type(self):
- return self._response_schema(next_states={"change_package_type", "summary"})
+ def set_packaging(self):
+ return self._response_schema(next_states={"change_packaging", "summary"})
def remove_package(self):
return self._response_schema(next_states={"summary"})
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 63bede3dad..e4762d247b 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -82,7 +82,7 @@ def package(self):
"name": {"type": "string", "nullable": False, "required": True},
"weight": {"required": True, "nullable": True, "type": "float"},
"line_count": {"required": True, "nullable": True, "type": "integer"},
- "package_type_name": {"required": True, "nullable": True, "type": "string"},
+ "packaging_name": {"required": True, "nullable": True, "type": "string"},
}
def lot(self):
@@ -97,7 +97,7 @@ def location(self):
"name": {"type": "string", "nullable": False, "required": True},
}
- def package_type(self):
+ def packaging(self):
return {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 709fa5cd76..d1fcb46556 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -20,4 +20,5 @@
from . import test_checkout_new_package
from . import test_checkout_list_package
from . import test_checkout_summary
+from . import test_checkout_change_packaging
from . import test_delivery_base
diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py
new file mode 100644
index 0000000000..71c2883a37
--- /dev/null
+++ b/shopfloor/tests/test_checkout_change_packaging.py
@@ -0,0 +1,96 @@
+from .test_checkout_base import CheckoutCommonCase
+
+
+class CheckoutListPackagingCase(CheckoutCommonCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.packaging_pallet = cls.env["product.packaging"].create(
+ {
+ "sequence": 3,
+ "name": "Pallet",
+ "barcode": "PPP",
+ "height": 100,
+ "width": 100,
+ "lngth": 100,
+ }
+ )
+ cls.packaging_box = cls.env["product.packaging"].create(
+ {
+ "sequence": 2,
+ "name": "Box",
+ "barcode": "BBB",
+ "height": 20,
+ "width": 20,
+ "lngth": 20,
+ }
+ )
+ cls.packaging_inner_box = cls.env["product.packaging"].create(
+ {
+ "sequence": 1,
+ "name": "Inner Box",
+ "barcode": "III",
+ "height": 10,
+ "width": 10,
+ "lngth": 10,
+ }
+ )
+
+ def test_list_packaging_ok(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ package = picking.move_line_ids.result_package_id
+ response = self.service.dispatch(
+ "list_packaging",
+ params={"picking_id": picking.id, "package_id": package.id},
+ )
+
+ self.assert_response(
+ response,
+ next_state="change_packaging",
+ data={
+ "picking": {
+ "id": picking.id,
+ "name": picking.name,
+ "note": "",
+ "origin": "",
+ "line_count": len(picking.move_line_ids),
+ "partner": {"id": self.customer.id, "name": self.customer.name},
+ },
+ "package": {
+ "id": package.id,
+ "name": package.name,
+ # TODO
+ "weight": 0,
+ "line_count": 1,
+ "packaging_name": package.product_packaging_id.name or "",
+ },
+ "packagings": [
+ {
+ "id": self.packaging_inner_box.id,
+ "name": self.packaging_inner_box.name,
+ },
+ {"id": self.packaging_box.id, "name": self.packaging_box.name},
+ {
+ "id": self.packaging_pallet.id,
+ "name": self.packaging_pallet.name,
+ },
+ ],
+ },
+ )
+
+ def test_list_packaging_error_package_not_found(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ response = self.service.dispatch(
+ "list_packaging", params={"picking_id": picking.id, "package_id": 0}
+ )
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={"picking": self._stock_picking_data(picking)},
+ message={
+ "message_type": "error",
+ "message": "The record you were working on does not exist anymore.",
+ },
+ )
diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py
index 9aa13fd6e3..1af133148d 100644
--- a/shopfloor/tests/test_checkout_scan_package_action.py
+++ b/shopfloor/tests/test_checkout_scan_package_action.py
@@ -291,9 +291,6 @@ def test_scan_package_action_scan_packaging_ok(self):
move_line2.qty_done = move_line2.product_uom_qty
move_line3.qty_done = 0
- packaging_type = self.env["stock.package.storage.type"].create(
- {"name": "Pallet"}
- )
packaging = self.env["product.packaging"].create(
{
"name": "Pallet",
@@ -301,7 +298,6 @@ def test_scan_package_action_scan_packaging_ok(self):
"height": 12,
"width": 13,
"lngth": 14,
- "package_storage_type_id": packaging_type.id,
}
)
@@ -322,8 +318,7 @@ def test_scan_package_action_scan_packaging_ok(self):
new_package,
[
{
- "package_storage_type_id": packaging_type.id,
- "packaging_id": packaging.id,
+ "product_packaging_id": packaging.id,
"lngth": packaging.lngth,
"width": packaging.width,
"height": packaging.height,
From fb17690a65f5681ca6c605777eba6757358080ef Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 27 Mar 2020 11:47:46 +0100
Subject: [PATCH 166/940] checkout: implement /set_packaging
---
shopfloor/services/checkout.py | 21 ++++-
.../tests/test_checkout_change_packaging.py | 90 +++++++++++++++----
2 files changed, 95 insertions(+), 16 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 8f31b97385..b3d2edf65c 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -928,7 +928,26 @@ def set_packaging(self, picking_id, package_id, packaging_id):
* change_packaging: in case of error
* summary
"""
- return self._response()
+ message = self.actions_for("message")
+
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+
+ package = self.env["stock.quant.package"].browse(package_id).exists()
+ packaging = self.env["product.packaging"].browse(packaging_id).exists()
+ if not (package and packaging):
+ return self._response_for_summary(
+ picking, message=message.record_not_found()
+ )
+ package.product_packaging_id = packaging
+ return self._response_for_summary(
+ picking,
+ message={
+ "message_type": "success",
+ "message": _("Packaging changed on package {}").format(package.name),
+ },
+ )
def remove_package(self, picking_id, package_id):
"""Remove destination package from move lines and set qty done to 0
diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py
index 71c2883a37..929809f5f7 100644
--- a/shopfloor/tests/test_checkout_change_packaging.py
+++ b/shopfloor/tests/test_checkout_change_packaging.py
@@ -1,7 +1,7 @@
from .test_checkout_base import CheckoutCommonCase
-class CheckoutListPackagingCase(CheckoutCommonCase):
+class CheckoutListSetPackagingCase(CheckoutCommonCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
@@ -35,15 +35,16 @@ def setUpClass(cls):
"lngth": 10,
}
)
+ cls.picking = cls._create_picking(lines=[(cls.product_a, 10)])
+ cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True)
+ cls.picking.action_assign()
+ cls.package = cls.picking.move_line_ids.result_package_id
+ cls.package.product_packaging_id = cls.packaging_pallet
def test_list_packaging_ok(self):
- picking = self._create_picking(lines=[(self.product_a, 10)])
- self._fill_stock_for_moves(picking.move_lines, in_package=True)
- picking.action_assign()
- package = picking.move_line_ids.result_package_id
response = self.service.dispatch(
"list_packaging",
- params={"picking_id": picking.id, "package_id": package.id},
+ params={"picking_id": self.picking.id, "package_id": self.package.id},
)
self.assert_response(
@@ -51,20 +52,20 @@ def test_list_packaging_ok(self):
next_state="change_packaging",
data={
"picking": {
- "id": picking.id,
- "name": picking.name,
+ "id": self.picking.id,
+ "name": self.picking.name,
"note": "",
"origin": "",
- "line_count": len(picking.move_line_ids),
+ "line_count": len(self.picking.move_line_ids),
"partner": {"id": self.customer.id, "name": self.customer.name},
},
"package": {
- "id": package.id,
- "name": package.name,
+ "id": self.package.id,
+ "name": self.package.name,
# TODO
"weight": 0,
"line_count": 1,
- "packaging_name": package.product_packaging_id.name or "",
+ "packaging_name": self.package.product_packaging_id.name or "",
},
"packagings": [
{
@@ -81,14 +82,73 @@ def test_list_packaging_ok(self):
)
def test_list_packaging_error_package_not_found(self):
- picking = self._create_picking(lines=[(self.product_a, 10)])
response = self.service.dispatch(
- "list_packaging", params={"picking_id": picking.id, "package_id": 0}
+ "list_packaging", params={"picking_id": self.picking.id, "package_id": 0}
)
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(picking)},
+ data={"picking": self._stock_picking_data(self.picking)},
+ message={
+ "message_type": "error",
+ "message": "The record you were working on does not exist anymore.",
+ },
+ )
+
+ def test_set_packaging_ok(self):
+ response = self.service.dispatch(
+ "set_packaging",
+ params={
+ "picking_id": self.picking.id,
+ "package_id": self.package.id,
+ "packaging_id": self.packaging_inner_box.id,
+ },
+ )
+ self.assertRecordValues(
+ self.package, [{"product_packaging_id": self.packaging_inner_box.id}]
+ )
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={"picking": self._stock_picking_data(self.picking)},
+ message={
+ "message_type": "success",
+ "message": "Packaging changed on package {}".format(self.package.name),
+ },
+ )
+
+ def test_set_packaging_error_package_not_found(self):
+ response = self.service.dispatch(
+ "set_packaging",
+ params={
+ "picking_id": self.picking.id,
+ "package_id": 0,
+ "packaging_id": self.packaging_inner_box.id,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={"picking": self._stock_picking_data(self.picking)},
+ message={
+ "message_type": "error",
+ "message": "The record you were working on does not exist anymore.",
+ },
+ )
+
+ def test_set_packaging_error_packaging_not_found(self):
+ response = self.service.dispatch(
+ "set_packaging",
+ params={
+ "picking_id": self.picking.id,
+ "package_id": self.package.id,
+ "packaging_id": 0,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={"picking": self._stock_picking_data(self.picking)},
message={
"message_type": "error",
"message": "The record you were working on does not exist anymore.",
From f05c53715a51f2e8c7ca5af6df03254d67eabb50 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 27 Mar 2020 13:25:11 +0100
Subject: [PATCH 167/940] checkout: implement /remove_package
---
shopfloor/services/checkout.py | 43 ++++++--
shopfloor/tests/__init__.py | 1 +
.../tests/test_checkout_remove_package.py | 104 ++++++++++++++++++
3 files changed, 140 insertions(+), 8 deletions(-)
create mode 100644 shopfloor/tests/test_checkout_remove_package.py
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index b3d2edf65c..9489223fef 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -5,10 +5,6 @@
from .service import to_float
-# NOTE: we need to know if the destination package is set, but sometimes
-# the dest. package is kept, so we will use field shopfloor_checkout_packed
-# on the move line
-
class Checkout(Component):
"""
@@ -27,6 +23,9 @@ class Checkout(Component):
5) Products are not packed (e.g. raw products) and we create new packages
6) Products are not packed (e.g. raw products) and we do not create packages
+ A new flag ``shopfloor_checkout_packed`` on move lines allows to track which
+ lines have been put in a package.
+
Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
"""
@@ -225,9 +224,7 @@ def _data_for_stock_picking(self, picking):
return data
def _lines_to_pack(self, picking):
- return picking.move_line_ids.filtered(
- lambda l: l.qty_done == 0 and not l.shopfloor_checkout_packed
- )
+ return picking.move_line_ids.filtered(self._filter_lines_unpacked)
def _domain_for_list_stock_picking(self):
return [
@@ -602,10 +599,18 @@ def _switch_line_qty_done(self, picking, selected_lines, switch_lines):
lambda l: l.product_uom_qty,
)
+ @staticmethod
+ def _filter_lines_unpacked(move_line):
+ return move_line.qty_done == 0 and not move_line.shopfloor_checkout_packed
+
@staticmethod
def _filter_lines_to_pack(move_line):
return move_line.qty_done > 0 and not move_line.shopfloor_checkout_packed
+ @staticmethod
+ def _filter_lines_packed(move_line):
+ return move_line.qty_done > 0 and move_line.shopfloor_checkout_packed
+
def _is_package_allowed(self, picking, package):
existing_packages = picking.mapped("move_line_ids.result_package_id")
return package in existing_packages
@@ -955,11 +960,33 @@ def remove_package(self, picking_id, package_id):
All the move lines with the package as ``result_package_id`` have their
``result_package_id`` reset to the source package (default odoo behavior)
and their ``qty_done`` set to 0.
+ It flags ``shopfloor_checkout_packed`` to False so they have to be packed again.
Transitions:
* summary
"""
- return self._response()
+ message = self.actions_for("message")
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+
+ package = self.env["stock.quant.package"].browse(package_id).exists()
+ if not package:
+ return self._response_for_summary(
+ picking, message=message.record_not_found()
+ )
+ move_lines = picking.move_line_ids.filtered(
+ lambda l: self._filter_lines_packed(l) and l.result_package_id == package
+ )
+ for move_line in move_lines:
+ move_line.write(
+ {
+ "qty_done": 0,
+ "result_package_id": move_line.package_id,
+ "shopfloor_checkout_packed": False,
+ }
+ )
+ return self._response_for_summary(picking)
def done(self, picking_id, confirmation=False):
"""Set the moves as done
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index d1fcb46556..408731f3bf 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -21,4 +21,5 @@
from . import test_checkout_list_package
from . import test_checkout_summary
from . import test_checkout_change_packaging
+from . import test_checkout_remove_package
from . import test_delivery_base
diff --git a/shopfloor/tests/test_checkout_remove_package.py b/shopfloor/tests/test_checkout_remove_package.py
new file mode 100644
index 0000000000..9a2d407118
--- /dev/null
+++ b/shopfloor/tests/test_checkout_remove_package.py
@@ -0,0 +1,104 @@
+from .test_checkout_base import CheckoutCommonCase
+
+
+class CheckoutRemovePackageCase(CheckoutCommonCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.picking = picking = cls._create_picking(
+ lines=[
+ (cls.product_a, 10),
+ (cls.product_b, 10),
+ (cls.product_c, 10),
+ (cls.product_d, 10),
+ ]
+ )
+ cls.pack1_moves = picking.move_lines[:2]
+ cls.pack2_moves = picking.move_lines[2]
+ cls.raw_move = picking.move_lines[3]
+ cls._fill_stock_for_moves(cls.pack1_moves, in_package=True)
+ cls._fill_stock_for_moves(cls.pack2_moves, in_package=True)
+ cls._fill_stock_for_moves(cls.raw_move)
+ picking.action_assign()
+
+ def test_remove_package_ok(self):
+ picking = self.picking
+
+ pack1_lines = self.pack1_moves.move_line_ids
+ pack2_lines = self.pack2_moves.move_line_ids
+ raw_line = self.raw_move.move_line_ids
+
+ # do as we packed the lines in 2 different packages
+ new_package = self.env["stock.quant.package"].create({})
+ (pack1_lines | raw_line).write(
+ {
+ "qty_done": 10,
+ "result_package_id": new_package.id,
+ "shopfloor_checkout_packed": True,
+ }
+ )
+ new_package2 = self.env["stock.quant.package"].create({})
+ pack2_lines.write(
+ {
+ "qty_done": 10,
+ "result_package_id": new_package2.id,
+ "shopfloor_checkout_packed": True,
+ }
+ )
+
+ # and now, we want to drop the new_package
+ response = self.service.dispatch(
+ "remove_package",
+ params={"picking_id": picking.id, "package_id": new_package.id},
+ )
+
+ self.assertRecordValues(
+ pack1_lines + raw_line + pack2_lines,
+ [
+ {
+ "qty_done": 0,
+ # reset to origin package
+ "result_package_id": pack1_lines.mapped("package_id").id,
+ "shopfloor_checkout_packed": False,
+ },
+ {
+ "qty_done": 0,
+ # reset to origin package
+ "result_package_id": pack1_lines.mapped("package_id").id,
+ "shopfloor_checkout_packed": False,
+ },
+ {
+ "qty_done": 0,
+ # result to an empty package (raw product)
+ "result_package_id": False,
+ "shopfloor_checkout_packed": False,
+ },
+ # different package, leave untouched
+ {
+ "qty_done": 10,
+ "result_package_id": new_package2.id,
+ "shopfloor_checkout_packed": True,
+ },
+ ],
+ )
+
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={"picking": self._stock_picking_data(picking)},
+ )
+
+ def test_remove_package_error_package_not_found(self):
+ # and now, we want to drop the new_package
+ response = self.service.dispatch(
+ "remove_package", params={"picking_id": self.picking.id, "package_id": 0}
+ )
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={"picking": self._stock_picking_data(self.picking)},
+ message={
+ "message_type": "error",
+ "message": "The record you were working on does not exist anymore.",
+ },
+ )
From a693044f426d3333705e1e109939a7170a2b7e84 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 27 Mar 2020 14:24:59 +0100
Subject: [PATCH 168/940] checkout: implement /done
---
shopfloor/services/checkout.py | 48 +++++++++-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_checkout_done.py | 131 ++++++++++++++++++++++++++
3 files changed, 177 insertions(+), 3 deletions(-)
create mode 100644 shopfloor/tests/test_checkout_done.py
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 9489223fef..31c5a111b1 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -111,9 +111,9 @@ def _response_for_selected_stock_picking(self, picking, message=None):
message=message,
)
- def _response_for_summary(self, picking, message=None):
+ def _response_for_summary(self, picking, need_confirm=False, message=None):
return self._response(
- next_state="summary",
+ next_state="summary" if not need_confirm else "confirm_done",
data={"picking": self._data_for_stock_picking(picking)},
message=message,
)
@@ -988,6 +988,13 @@ def remove_package(self, picking_id, package_id):
)
return self._response_for_summary(picking)
+ @staticmethod
+ def _filter_lines_(move_line):
+ return (
+ move_line.qty_done == move_line.product_uom_qty
+ and move_line.shopfloor_checkout_packed
+ )
+
def done(self, picking_id, confirmation=False):
"""Set the moves as done
@@ -999,7 +1006,42 @@ def done(self, picking_id, confirmation=False):
* select_document: after done, goes back to start
* confirm_done: confirm a partial
"""
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+ lines = picking.move_line_ids
+ if not confirmation:
+ if not all(line.qty_done == line.product_uom_qty for line in lines):
+ return self._response_for_summary(
+ picking,
+ need_confirm=True,
+ message={
+ "message_type": "warning",
+ "message": _(
+ "Not all lines have been processed, do you"
+ " want to confirm partial operation?"
+ ),
+ },
+ )
+ elif not all(line.shopfloor_checkout_packed for line in lines):
+ return self._response_for_summary(
+ picking,
+ need_confirm=True,
+ message={
+ "message_type": "warning",
+ "message": _(
+ "Remaining raw product not packed, proceed anyway?"
+ ),
+ },
+ )
+ picking.action_done()
+ return self._response(
+ next_state="select_document",
+ message={
+ "message_type": "success",
+ "message": _("Transfer {} done").format(picking.name),
+ },
+ )
class ShopfloorCheckoutValidator(Component):
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 408731f3bf..4f4fe71172 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -22,4 +22,5 @@
from . import test_checkout_summary
from . import test_checkout_change_packaging
from . import test_checkout_remove_package
+from . import test_checkout_done
from . import test_delivery_base
diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py
new file mode 100644
index 0000000000..373fb2727e
--- /dev/null
+++ b/shopfloor/tests/test_checkout_done.py
@@ -0,0 +1,131 @@
+from .test_checkout_base import CheckoutCommonCase
+
+
+class CheckoutDoneCase(CheckoutCommonCase):
+ def test_done_ok(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ # line is done
+ picking.move_line_ids.write({"qty_done": 10, "shopfloor_checkout_packed": True})
+ response = self.service.dispatch("done", params={"picking_id": picking.id})
+
+ self.assertRecordValues(picking, [{"state": "done"}])
+
+ self.assert_response(
+ response,
+ next_state="select_document",
+ message={
+ "message_type": "success",
+ "message": "Transfer {} done".format(picking.name),
+ },
+ )
+
+
+class CheckoutDonePartialCase(CheckoutCommonCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.picking = picking = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10)]
+ )
+ cls._fill_stock_for_moves(picking.move_lines)
+ picking.action_assign()
+ cls.line1 = picking.move_line_ids[0]
+ cls.line2 = picking.move_line_ids[1]
+ cls.line1.write({"qty_done": 10, "shopfloor_checkout_packed": True})
+ cls.line2.write({"qty_done": 2, "shopfloor_checkout_packed": True})
+
+ def test_done_partial(self):
+ # line is done
+ response = self.service.dispatch("done", params={"picking_id": self.picking.id})
+
+ self.assertRecordValues(self.picking, [{"state": "assigned"}])
+
+ self.assert_response(
+ response,
+ next_state="confirm_done",
+ data={"picking": self._stock_picking_data(self.picking)},
+ message={
+ "message_type": "warning",
+ "message": "Not all lines have been processed, do"
+ " you want to confirm partial operation?",
+ },
+ )
+
+ def test_done_partial_confirm(self):
+ # line is done
+ response = self.service.dispatch(
+ "done", params={"picking_id": self.picking.id, "confirmation": True}
+ )
+
+ self.assertRecordValues(self.picking, [{"state": "done"}])
+ self.assertTrue(self.picking.backorder_ids)
+
+ self.assert_response(
+ response,
+ next_state="select_document",
+ message={
+ "message_type": "success",
+ "message": "Transfer {} done".format(self.picking.name),
+ },
+ )
+
+
+class CheckoutDoneRawUnpackedCase(CheckoutCommonCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.picking = picking = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10)]
+ )
+ cls._fill_stock_for_moves(picking.move_lines)
+ picking.action_assign()
+ cls.line1 = picking.move_line_ids[0]
+ cls.line2 = picking.move_line_ids[1]
+ cls.package = cls.env["stock.quant.package"].create({})
+ cls.line1.write(
+ {
+ "qty_done": 10,
+ "shopfloor_checkout_packed": True,
+ "result_package_id": cls.package.id,
+ }
+ )
+ cls.line2.write({"qty_done": 10, "shopfloor_checkout_packed": False})
+
+ def test_done_partial(self):
+ # line is done
+ response = self.service.dispatch("done", params={"picking_id": self.picking.id})
+
+ self.assertRecordValues(self.picking, [{"state": "assigned"}])
+
+ self.assert_response(
+ response,
+ next_state="confirm_done",
+ data={"picking": self._stock_picking_data(self.picking)},
+ message={
+ "message_type": "warning",
+ "message": "Remaining raw product not packed, proceed anyway?",
+ },
+ )
+
+ def test_done_partial_confirm(self):
+ # line is done
+ response = self.service.dispatch(
+ "done", params={"picking_id": self.picking.id, "confirmation": True}
+ )
+
+ self.assertRecordValues(self.picking, [{"state": "done", "backorder_ids": []}])
+ self.assertRecordValues(
+ self.line1 + self.line2,
+ [{"result_package_id": self.package.id}, {"result_package_id": False}],
+ )
+
+ self.assert_response(
+ response,
+ next_state="select_document",
+ message={
+ "message_type": "success",
+ "message": "Transfer {} done".format(self.picking.name),
+ },
+ )
From 080e60e82000eca5a7c2222bc91d71164a2f3e88 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 30 Mar 2020 13:34:12 +0200
Subject: [PATCH 169/940] checkout: use common methods for getting data for
endpoints
The idea is to share data and validation schemas for data returned
by endpoints, per model, across the different scenarii.
Currently, only the checkout uses it, but the new ones we implement
should use the same schemas and data methods as much as possible.
Ideally, we should rework the existing ones to use them.
---
shopfloor/actions/data.py | 67 +++++
shopfloor/services/checkout.py | 110 ++------
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/common.py | 76 ++++++
shopfloor/tests/test_actions_data.py | 250 ++++++++++++++++++
shopfloor/tests/test_checkout_base.py | 55 +---
.../tests/test_checkout_change_packaging.py | 31 +--
shopfloor/tests/test_checkout_list_package.py | 2 +-
shopfloor/tests/test_checkout_select.py | 31 +--
.../test_checkout_select_package_base.py | 9 +-
shopfloor/tests/test_cluster_picking_base.py | 18 --
11 files changed, 436 insertions(+), 214 deletions(-)
create mode 100644 shopfloor/tests/test_actions_data.py
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index ee2b903950..9cab2186c6 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -11,3 +11,70 @@ class DataAction(Component):
_name = "shopfloor.data.action"
_inherit = "shopfloor.process.action"
_usage = "data"
+
+ def picking_summary(self, picking):
+ return {
+ "id": picking.id,
+ "name": picking.name,
+ "origin": picking.origin or "",
+ "note": picking.note or "",
+ "line_count": len(picking.move_line_ids),
+ "partner": {"id": picking.partner_id.id, "name": picking.partner_id.name}
+ if picking.partner_id
+ else None,
+ }
+
+ def package(self, package, picking=None):
+ """Return data for a stock.quant.package
+
+ If a picking is given, it will include the number of lines of the package
+ for the picking.
+ """
+ line_count = (
+ len(picking.move_line_ids.filtered(lambda l: l.package_id == package))
+ if picking
+ else 0
+ )
+ return {
+ "id": package.id,
+ "name": package.name,
+ # TODO
+ "weight": 0,
+ "line_count": line_count,
+ "packaging_name": package.product_packaging_id.name or "",
+ }
+
+ def packaging(self, packaging):
+ return {"id": packaging.id, "name": packaging.name}
+
+ def lot(self, lot):
+ return {"id": lot.id, "name": lot.name}
+
+ def location(self, location):
+ return {"id": location.id, "name": location.name}
+
+ def move_line(self, move_line):
+ return {
+ "id": move_line.id,
+ "qty_done": move_line.qty_done,
+ "quantity": move_line.product_uom_qty,
+ "product": {
+ "id": move_line.product_id.id,
+ "name": move_line.product_id.name,
+ "display_name": move_line.product_id.display_name,
+ "default_code": move_line.product_id.default_code or "",
+ },
+ "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name}
+ if move_line.lot_id
+ else None,
+ "package_src": self.package(move_line.package_id, move_line.picking_id)
+ if move_line.package_id
+ else None,
+ "package_dest": self.package(
+ move_line.result_package_id, move_line.picking_id
+ )
+ if move_line.result_package_id
+ else None,
+ "location_src": self.location(move_line.location_id),
+ "location_dest": self.location(move_line.location_dest_id),
+ }
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 31c5a111b1..f30383c6ff 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -162,62 +162,13 @@ def _response_for_stock_picking_has_been_deleted(self):
next_state="select_document", message=message.stock_picking_not_found()
)
- def _data_for_move_line(self, move_line):
- return {
- "id": move_line.id,
- "qty_done": move_line.qty_done,
- "quantity": move_line.product_uom_qty,
- "product": {
- "id": move_line.product_id.id,
- "name": move_line.product_id.name,
- "display_name": move_line.product_id.display_name,
- "default_code": move_line.product_id.default_code or "",
- },
- "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name}
- if move_line.lot_id
- else None,
- "package_src": {
- "id": move_line.package_id.id,
- "name": move_line.package_id.name,
- # TODO
- "weight": 0,
- # TODO
- "line_count": 0,
- "packaging_name": (
- move_line.package_id.product_packaging_id.name or ""
- ),
- }
- if move_line.package_id
- else None,
- "package_dest": {
- "id": move_line.result_package_id.id,
- "name": move_line.result_package_id.name,
- # TODO
- "weight": 0,
- # TODO
- "line_count": 0,
- "packaging_name": (
- move_line.result_package_id.product_packaging_id.name or ""
- ),
- }
- if move_line.result_package_id
- else None,
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "location_dest": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
- }
-
def _data_for_stock_picking(self, picking):
- data = self._data_picking_base(picking)
+ data_struct = self.actions_for("data")
+ data = data_struct.picking_summary(picking)
data.update(
{
"move_lines": [
- self._data_for_move_line(ml) for ml in self._lines_to_pack(picking)
+ data_struct.move_line(ml) for ml in self._lines_to_pack(picking)
]
}
)
@@ -251,20 +202,11 @@ def _response_for_manual_selection(self, message=None):
self._domain_for_list_stock_picking(),
order=self._order_for_list_stock_picking(),
)
- data = {"pickings": [self._data_picking_base(picking) for picking in pickings]}
- return self._response(next_state="manual_selection", data=data, message=message)
-
- def _data_picking_base(self, picking):
- return {
- "id": picking.id,
- "name": picking.name,
- "origin": picking.origin or "",
- "note": picking.note or "",
- "line_count": len(picking.move_line_ids),
- "partner": {"id": picking.partner_id.id, "name": picking.partner_id.name}
- if picking.partner_id
- else None,
+ data_struct = self.actions_for("data")
+ data = {
+ "pickings": [data_struct.picking_summary(picking) for picking in pickings]
}
+ return self._response(next_state="manual_selection", data=data, message=message)
def select(self, picking_id):
"""Select a stock picking for the process
@@ -288,14 +230,15 @@ def select(self, picking_id):
return self._select_picking(picking, "manual_selection")
def _response_for_select_package(self, lines, message=None):
+ data_struct = self.actions_for("data")
picking = lines.mapped("picking_id")
return self._response(
next_state="select_package",
data={
"selected_move_lines": [
- self._data_for_move_line(line) for line in lines.sorted()
+ data_struct.move_line(line) for line in lines.sorted()
],
- "picking": self._data_picking_base(picking),
+ "picking": data_struct.picking_summary(picking),
},
message=message,
)
@@ -755,19 +698,6 @@ def new_package(self, picking_id, selected_line_ids):
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
return self._create_and_assign_new_packaging(picking, selected_lines)
- def _data_package(self, picking, package):
- line_count = len(
- picking.move_line_ids.filtered(lambda l: l.package_id == package)
- )
- return {
- "id": package.id,
- "name": package.name,
- # TODO
- "weight": 0,
- "line_count": line_count,
- "packaging_name": package.product_packaging_id.name or "",
- }
-
def list_dest_package(self, picking_id, selected_line_ids):
"""Return a list of packages the user can select for the lines
@@ -797,17 +727,20 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None):
"message": _("No valid package to select."),
},
)
- picking_data = self._data_picking_base(picking)
+ data_struct = self.actions_for("data")
+ picking_data = data_struct.picking_summary(picking)
packages_data = [
- self._data_package(picking, package) for package in packages.sorted()
+ data_struct.package(package, picking=picking)
+ for package in packages.sorted()
]
+ data_struct = self.actions_for("data")
return self._response(
next_state="select_dest_package",
data={
"picking": picking_data,
"packages": packages_data,
"selected_move_lines": [
- self._data_for_move_line(line) for line in move_lines.sorted()
+ data_struct.move_line(line) for line in move_lines.sorted()
],
},
message=message,
@@ -884,9 +817,6 @@ def summary(self, picking_id):
return self._response_stock_picking_does_not_exist()
return self._response_for_summary(picking)
- def _data_packaging(self, packaging):
- return {"id": packaging.id, "name": packaging.name}
-
def _get_allowed_packaging(self):
return self.env["product.packaging"].search([("product_id", "=", False)])
@@ -910,17 +840,19 @@ def list_packaging(self, picking_id, package_id):
def _response_for_change_packaging(self, picking, package, packaging_list):
message = self.actions_for("message")
+ data_struct = self.actions_for("data")
if not package:
return self._response_for_summary(
picking, message=message.record_not_found()
)
+
return self._response(
next_state="change_packaging",
data={
- "picking": self._data_picking_base(picking),
- "package": self._data_package(picking, package),
+ "picking": data_struct.picking_summary(picking),
+ "package": data_struct.package(package, picking=picking),
"packagings": [
- self._data_packaging(packaging)
+ data_struct.packaging(packaging)
for packaging in packaging_list.sorted()
],
},
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 4f4fe71172..8911b00f82 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -2,6 +2,7 @@
from . import test_menu
from . import test_openapi
from . import test_profile
+from . import test_actions_data
from . import test_picking_batch
from . import test_single_pack_putaway
from . import test_single_pack_transfer
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 2ae7f007ee..3342045245 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -39,6 +39,14 @@ def work_on_services(self, **params):
model_name="rest.service.registration", collection=collection, **params
)
+ @contextmanager
+ def work_on_actions(self, **params):
+ params = params or {}
+ collection = _PseudoCollection("shopfloor.action", self.env)
+ yield WorkContext(
+ model_name="rest.service.registration", collection=collection, **params
+ )
+
# pylint: disable=method-required-super
# super is called "the old-style way" to call both super classes in the
# order we want
@@ -56,6 +64,7 @@ def setUpClass(cls):
)
cls.setUpComponent()
cls.setUpClassVars()
+ cls.setUpClassBaseData()
@classmethod
def setUpClassVars(cls):
@@ -69,6 +78,57 @@ def setUpClassVars(cls):
cls.shelf2 = cls.env.ref("stock.stock_location_14")
cls.customer = cls.env["res.partner"].create({"name": "Customer"})
+ @classmethod
+ def setUpClassBaseData(cls):
+ cls.product_a = cls.env["product.product"].create(
+ {
+ "name": "Product A",
+ "type": "product",
+ "default_code": "A",
+ "barcode": "A",
+ "weight": 2,
+ }
+ )
+ cls.product_a_packaging = cls.env["product.packaging"].create(
+ {"name": "Box", "product_id": cls.product_a.id, "barcode": "ProductABox"}
+ )
+ cls.product_b = cls.env["product.product"].create(
+ {
+ "name": "Product B",
+ "type": "product",
+ "default_code": "B",
+ "barcode": "B",
+ "weight": 3,
+ }
+ )
+ cls.product_b_packaging = cls.env["product.packaging"].create(
+ {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox"}
+ )
+ cls.product_c = cls.env["product.product"].create(
+ {
+ "name": "Product C",
+ "type": "product",
+ "default_code": "C",
+ "barcode": "C",
+ "weight": 3,
+ }
+ )
+ cls.product_c_packaging = cls.env["product.packaging"].create(
+ {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox"}
+ )
+ cls.product_d = cls.env["product.product"].create(
+ {
+ "name": "Product D",
+ "type": "product",
+ "default_code": "D",
+ "barcode": "D",
+ "weight": 3,
+ }
+ )
+ cls.product_d_packaging = cls.env["product.packaging"].create(
+ {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox"}
+ )
+
def assert_response(self, response, next_state=None, message=None, data=None):
"""Assert a response from the webservice
@@ -91,6 +151,22 @@ def assert_response(self, response, next_state=None, message=None, data=None):
"\n\nExpected:\n%s" % (pformat(response), pformat(expected)),
)
+ @classmethod
+ def _create_picking(cls, picking_type=None, lines=None, confirm=True):
+ picking_form = Form(cls.env["stock.picking"])
+ picking_form.picking_type_id = picking_type or cls.picking_type
+ picking_form.partner_id = cls.customer
+ if lines is None:
+ lines = [(cls.product_a, 10), (cls.product_b, 10)]
+ for product, qty in lines:
+ with picking_form.move_ids_without_package.new() as move:
+ move.product_id = product
+ move.product_uom_qty = qty
+ picking = picking_form.save()
+ if confirm:
+ picking.action_confirm()
+ return picking
+
@classmethod
def _update_qty_in_location(
cls, location, product, quantity, package=None, lot=None
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
new file mode 100644
index 0000000000..c8f2207b9e
--- /dev/null
+++ b/shopfloor/tests/test_actions_data.py
@@ -0,0 +1,250 @@
+import logging
+
+from .common import CommonCase
+
+_logger = logging.getLogger(__name__)
+
+
+try:
+ from cerberus import Validator
+except ImportError:
+ _logger.debug("Can not import cerberus")
+
+
+class ActionsDataCase(CommonCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.wh = cls.env.ref("stock.warehouse0")
+ cls.picking_type = cls.wh.out_type_id
+ cls.packaging = cls.env["product.packaging"].create({"name": "Pallet"})
+ cls.product_b.tracking = "lot"
+ cls.product_c.tracking = "lot"
+ cls.picking = cls._create_picking(
+ lines=[
+ (cls.product_a, 10),
+ (cls.product_b, 10),
+ (cls.product_c, 10),
+ (cls.product_d, 10),
+ ]
+ )
+ # put product A in a package
+ cls.move_a = cls.picking.move_lines[0]
+ cls._fill_stock_for_moves(cls.move_a, in_package=True)
+ # product B has a lot
+ cls.move_b = cls.picking.move_lines[1]
+ cls._fill_stock_for_moves(cls.move_b, in_lot=True)
+ # product C has a lot and package
+ cls.move_c = cls.picking.move_lines[2]
+ cls._fill_stock_for_moves(cls.move_c, in_package=True, in_lot=True)
+ # product D is raw
+ cls.move_d = cls.picking.move_lines[3]
+ cls._fill_stock_for_moves(cls.move_d)
+ cls.picking.action_assign()
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_actions() as work:
+ self.data = work.component(usage="data")
+ with self.work_on_services() as work:
+ self.schema = work.component(usage="schema")
+
+ def assert_schema(self, schema, data):
+ validator = Validator(schema)
+ self.assertTrue(validator.validate(data), validator.errors)
+
+ def test_data_packaging(self):
+ data = self.data.packaging(self.packaging)
+ self.assert_schema(self.schema.packaging(), data)
+ expected = {"id": self.packaging.id, "name": self.packaging.name}
+ self.assertDictEqual(data, expected)
+
+ def test_data_location(self):
+ location = self.stock_location
+ data = self.data.location(location)
+ self.assert_schema(self.schema.location(), data)
+ expected = {"id": location.id, "name": location.name}
+ self.assertDictEqual(data, expected)
+
+ def test_data_lot(self):
+ lot = self.env["stock.production.lot"].create(
+ {"product_id": self.product_b.id, "company_id": self.env.company.id}
+ )
+ data = self.data.lot(lot)
+ self.assert_schema(self.schema.lot(), data)
+ expected = {"id": lot.id, "name": lot.name}
+ self.assertDictEqual(data, expected)
+
+ def test_data_package(self):
+ package = self.move_a.move_line_ids.package_id
+ package.product_packaging_id = self.packaging.id
+ data = self.data.package(package, picking=self.picking)
+ self.assert_schema(self.schema.package(), data)
+ expected = {
+ "id": package.id,
+ "name": package.name,
+ "line_count": 1,
+ "packaging_name": self.packaging.name,
+ # TODO
+ "weight": 0,
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_data_picking_summary(self):
+ self.picking.write({"origin": "created by test", "note": "read me"})
+ data = self.data.picking_summary(self.picking)
+ self.assert_schema(self.schema.picking(), data)
+ expected = {
+ "id": self.picking.id,
+ "line_count": 4,
+ "name": self.picking.name,
+ "note": "read me",
+ "origin": "created by test",
+ "partner": {"id": self.customer.id, "name": self.customer.name},
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_data_move_line_package(self):
+ move_line = self.move_a.move_line_ids
+ result_package = self.env["stock.quant.package"].create(
+ {"product_packaging_id": self.packaging.id}
+ )
+ move_line.write({"qty_done": 3.0, "result_package_id": result_package.id})
+ data = self.data.move_line(move_line)
+ self.assert_schema(self.schema.move_line(), data)
+ expected = {
+ "id": move_line.id,
+ "qty_done": 3.0,
+ "quantity": move_line.product_uom_qty,
+ "product": {
+ "id": self.product_a.id,
+ "name": "Product A",
+ "display_name": "[A] Product A",
+ "default_code": "A",
+ },
+ "lot": None,
+ "package_src": {
+ "id": move_line.package_id.id,
+ "name": move_line.package_id.name,
+ "line_count": 1,
+ "packaging_name": "",
+ # TODO
+ "weight": 0,
+ },
+ "package_dest": {
+ "id": result_package.id,
+ "name": result_package.name,
+ "line_count": 0,
+ "packaging_name": self.packaging.name,
+ # TODO
+ "weight": 0,
+ },
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "location_dest": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_data_move_line_lot(self):
+ move_line = self.move_b.move_line_ids
+ data = self.data.move_line(move_line)
+ self.assert_schema(self.schema.move_line(), data)
+ expected = {
+ "id": move_line.id,
+ "qty_done": 0.0,
+ "quantity": move_line.product_uom_qty,
+ "product": {
+ "id": self.product_b.id,
+ "name": "Product B",
+ "display_name": "[B] Product B",
+ "default_code": "B",
+ },
+ "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name},
+ "package_src": None,
+ "package_dest": None,
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "location_dest": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_data_move_line_package_lot(self):
+ self.maxDiff = None
+ move_line = self.move_c.move_line_ids
+ data = self.data.move_line(move_line)
+ self.assert_schema(self.schema.move_line(), data)
+ expected = {
+ "id": move_line.id,
+ "qty_done": 0.0,
+ "quantity": move_line.product_uom_qty,
+ "product": {
+ "id": self.product_c.id,
+ "name": "Product C",
+ "display_name": "[C] Product C",
+ "default_code": "C",
+ },
+ "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name},
+ "package_src": {
+ "id": move_line.package_id.id,
+ "name": move_line.package_id.name,
+ "line_count": 1,
+ "packaging_name": "",
+ # TODO
+ "weight": 0,
+ },
+ "package_dest": {
+ "id": move_line.result_package_id.id,
+ "name": move_line.result_package_id.name,
+ "line_count": 1,
+ "packaging_name": "",
+ # TODO
+ "weight": 0,
+ },
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "location_dest": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_data_move_line_raw(self):
+ move_line = self.move_d.move_line_ids
+ data = self.data.move_line(move_line)
+ self.assert_schema(self.schema.move_line(), data)
+ expected = {
+ "id": move_line.id,
+ "qty_done": 0.0,
+ "quantity": move_line.product_uom_qty,
+ "product": {
+ "id": self.product_d.id,
+ "name": "Product D",
+ "display_name": "[D] Product D",
+ "default_code": "D",
+ },
+ "lot": None,
+ "package_src": None,
+ "package_dest": None,
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "location_dest": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ }
+ self.assertDictEqual(data, expected)
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index 8053ee6ace..cbf348b246 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -1,5 +1,3 @@
-from odoo.tests.common import Form
-
from .common import CommonCase
@@ -7,30 +5,6 @@ class CheckoutCommonCase(CommonCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
- cls.product_a = cls.env["product.product"].create(
- {"name": "Product A", "type": "product", "barcode": "product_a"}
- )
- cls.product_a_packaging = cls.env["product.packaging"].create(
- {"name": "Box", "product_id": cls.product_a.id, "barcode": "ProductABox"}
- )
- cls.product_b = cls.env["product.product"].create(
- {"name": "Product B", "type": "product", "barcode": "product_b"}
- )
- cls.product_b_packaging = cls.env["product.packaging"].create(
- {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox"}
- )
- cls.product_c = cls.env["product.product"].create(
- {"name": "Product C", "type": "product", "barcode": "product_c"}
- )
- cls.product_c_packaging = cls.env["product.packaging"].create(
- {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox"}
- )
- cls.product_d = cls.env["product.product"].create(
- {"name": "Product D", "type": "product", "barcode": "product_d"}
- )
- cls.product_d_packaging = cls.env["product.packaging"].create(
- {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox"}
- )
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout")
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
@@ -43,27 +17,18 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="checkout")
- @classmethod
- def _create_picking(cls, picking_type=None, lines=None, confirm=True):
- picking_form = Form(cls.env["stock.picking"])
- picking_form.picking_type_id = picking_type or cls.picking_type
- picking_form.partner_id = cls.customer
- if lines is None:
- lines = [(cls.product_a, 10), (cls.product_b, 10)]
- for product, qty in lines:
- with picking_form.move_ids_without_package.new() as move:
- move.product_id = product
- move.product_uom_qty = qty
- picking = picking_form.save()
- if confirm:
- picking.action_confirm()
- return picking
-
def _stock_picking_data(self, picking):
return self.service._data_for_stock_picking(picking)
+ # we test the methods that structure data in test_actions_data.py
+ def _picking_summary_data(self, picking):
+ return self.service.actions_for("data").picking_summary(picking)
+
def _move_line_data(self, move_line):
- return self.service._data_for_move_line(move_line)
+ return self.service.actions_for("data").move_line(move_line)
+
+ def _package_data(self, package, picking):
+ return self.service.actions_for("data").package(package, picking=picking)
- def _package_data(self, picking, package):
- return self.service._data_package(picking, package)
+ def _packaging_data(self, packaging):
+ return self.service.actions_for("data").packaging(packaging)
diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py
index 929809f5f7..d532830a1b 100644
--- a/shopfloor/tests/test_checkout_change_packaging.py
+++ b/shopfloor/tests/test_checkout_change_packaging.py
@@ -51,32 +51,13 @@ def test_list_packaging_ok(self):
response,
next_state="change_packaging",
data={
- "picking": {
- "id": self.picking.id,
- "name": self.picking.name,
- "note": "",
- "origin": "",
- "line_count": len(self.picking.move_line_ids),
- "partner": {"id": self.customer.id, "name": self.customer.name},
- },
- "package": {
- "id": self.package.id,
- "name": self.package.name,
- # TODO
- "weight": 0,
- "line_count": 1,
- "packaging_name": self.package.product_packaging_id.name or "",
- },
+ "picking": self._picking_summary_data(self.picking),
+ "package": self._package_data(self.package, self.picking),
"packagings": [
- {
- "id": self.packaging_inner_box.id,
- "name": self.packaging_inner_box.name,
- },
- {"id": self.packaging_box.id, "name": self.packaging_box.name},
- {
- "id": self.packaging_pallet.id,
- "name": self.packaging_pallet.name,
- },
+ self._packaging_data(packaging)
+ for packaging in self.packaging_inner_box
+ + self.packaging_box
+ + self.packaging_pallet
],
},
)
diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py
index dcbd7a59c9..e008422ac2 100644
--- a/shopfloor/tests/test_checkout_list_package.py
+++ b/shopfloor/tests/test_checkout_list_package.py
@@ -19,7 +19,7 @@ def _assert_response_select_dest_package(
"partner": {"id": self.customer.id, "name": self.customer.name},
},
"packages": [
- self._package_data(picking, package) for package in packages
+ self._package_data(package, picking) for package in packages
],
"selected_move_lines": [
self._move_line_data(ml) for ml in selected_lines.sorted()
diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py
index 8393a9763c..c8e30df13a 100644
--- a/shopfloor/tests/test_checkout_select.py
+++ b/shopfloor/tests/test_checkout_select.py
@@ -15,22 +15,8 @@ def test_list_stock_picking(self):
response = self.service.dispatch("list_stock_picking", params={})
expected = {
"pickings": [
- {
- "id": picking1.id,
- "line_count": len(picking1.move_line_ids),
- "name": picking1.name,
- "note": "",
- "origin": "",
- "partner": {"id": self.customer.id, "name": self.customer.name},
- },
- {
- "id": picking2.id,
- "line_count": len(picking2.move_line_ids),
- "name": picking2.name,
- "note": "",
- "origin": "",
- "partner": {"id": self.customer.id, "name": self.customer.name},
- },
+ self._picking_summary_data(picking1),
+ self._picking_summary_data(picking2),
]
}
@@ -61,18 +47,7 @@ def _test_error(self, picking, msg):
response,
next_state="manual_selection",
message={"message_type": "error", "message": msg},
- data={
- "pickings": [
- {
- "id": self.picking.id,
- "line_count": len(self.picking.move_line_ids),
- "name": self.picking.name,
- "note": "",
- "origin": "",
- "partner": {"id": self.customer.id, "name": self.customer.name},
- }
- ]
- },
+ data={"pickings": [self._picking_summary_data(self.picking)]},
)
def test_select_error_not_found(self):
diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py
index 4a66c075e4..e82ac9f9af 100644
--- a/shopfloor/tests/test_checkout_select_package_base.py
+++ b/shopfloor/tests/test_checkout_select_package_base.py
@@ -8,14 +8,7 @@ def _assert_selected_response(self, response, selected_lines, message=None):
"selected_move_lines": [
self._move_line_data(ml) for ml in selected_lines
],
- "picking": {
- "id": picking.id,
- "name": picking.name,
- "note": "",
- "origin": "",
- "line_count": len(picking.move_line_ids),
- "partner": {"id": self.customer.id, "name": self.customer.name},
- },
+ "picking": self._picking_summary_data(picking),
},
message=message,
)
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index 3d52804aff..62c1aa6550 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -5,24 +5,6 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
- cls.product_a = cls.env["product.product"].create(
- {
- "name": "Product A",
- "type": "product",
- "default_code": "A",
- "barcode": "A",
- "weight": 2,
- }
- )
- cls.product_b = cls.env["product.product"].create(
- {
- "name": "Product B",
- "type": "product",
- "default_code": "B",
- "barcode": "B",
- "weight": 3,
- }
- )
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
From 62bdc320de6cbe563490e7a808c57f82dd349bd7 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 30 Mar 2020 14:26:08 +0200
Subject: [PATCH 170/940] checkout: refactor _response_for methods
Only use one _response_for method for each next state.
We should use the same pattern for the other scenarii.
---
shopfloor/services/checkout.py | 104 +++++++++++++--------------------
1 file changed, 41 insertions(+), 63 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index f30383c6ff..da34175d40 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -57,6 +57,7 @@ def scan_document(self, barcode):
destination pack set
"""
search = self.actions_for("search")
+ message = self.actions_for("message")
picking = search.stock_picking_from_scan(barcode)
if not picking:
location = search.location_from_scan(barcode)
@@ -64,13 +65,23 @@ def scan_document(self, barcode):
if not location.is_sublocation_of(
self.picking_type.default_location_src_id
):
- return self._response_for_scan_location_not_allowed()
+ return self._response_for_select_document(
+ message=message.location_not_allowed()
+ )
lines = location.source_move_line_ids
pickings = lines.mapped("picking_id")
if len(pickings) == 1:
picking = pickings
else:
- return self._response_for_several_stock_picking_found()
+ return self._response_for_select_document(
+ message={
+ "message_type": "error",
+ "message": _(
+ "Several transfers found, please scan a package"
+ " or select a transfer manually."
+ ),
+ }
+ )
if not picking:
package = search.package_from_scan(barcode)
if package:
@@ -87,22 +98,28 @@ def _select_picking(self, picking, state_for_error):
return self._response_for_manual_selection(
message=message.stock_picking_not_found()
)
- return self._response_for_barcode_no_stock_picking_found()
+ return self._response_for_select_document(
+ message=message.barcode_not_found()
+ )
if picking.picking_type_id != self.picking_type:
if state_for_error == "manual_selection":
return self._response_for_manual_selection(
message=message.cannot_move_something_in_picking_type()
)
- return self._response_for_scan_picking_type_not_allowed()
+ return self._response_for_select_document(
+ message=message.cannot_move_something_in_picking_type()
+ )
if picking.state != "assigned":
if state_for_error == "manual_selection":
return self._response_for_manual_selection(
message=message.stock_picking_not_available(picking)
)
- return self._response_for_picking_not_assigned(picking)
- return self._response_for_selected_stock_picking(picking)
+ return self._response_for_select_document(
+ picking, message=message.stock_picking_not_available(picking)
+ )
+ return self._response_for_select_line(picking)
- def _response_for_selected_stock_picking(self, picking, message=None):
+ def _response_for_select_line(self, picking, message=None):
if all(line.shopfloor_checkout_packed for line in picking.move_line_ids):
return self._response_for_summary(picking, message=message)
return self._response(
@@ -118,49 +135,9 @@ def _response_for_summary(self, picking, need_confirm=False, message=None):
message=message,
)
- def _response_for_picking_not_assigned(self, picking):
- message = self.actions_for("message")
- return self._response(
- next_state="select_document",
- message=message.stock_picking_not_available(picking),
- )
-
- def _response_for_several_stock_picking_found(self):
- return self._response(
- next_state="select_document",
- message={
- "message_type": "error",
- "message": _(
- "Several transfers found, please scan a package"
- " or select a transfer manually."
- ),
- },
- )
-
- def _response_for_scan_picking_type_not_allowed(self):
- message = self.actions_for("message")
- return self._response(
- next_state="select_document",
- message=message.cannot_move_something_in_picking_type(),
- )
-
- def _response_for_scan_location_not_allowed(self):
- message = self.actions_for("message")
- return self._response(
- next_state="select_document", message=message.location_not_allowed()
- )
-
- def _response_for_barcode_no_stock_picking_found(self):
- message = self.actions_for("message")
- return self._response(
- next_state="select_document", message=message.barcode_not_found()
- )
-
- def _response_for_stock_picking_has_been_deleted(self):
+ def _response_for_select_document(self, picking, message=None):
message = self.actions_for("message")
- return self._response(
- next_state="select_document", message=message.stock_picking_not_found()
- )
+ return self._response(next_state="select_document", message=message)
def _data_for_stock_picking(self, picking):
data_struct = self.actions_for("data")
@@ -297,14 +274,14 @@ def scan_line(self, picking_id, barcode):
if lot:
return self._select_lines_from_lot(picking, selection_lines, lot)
- return self._response_for_selected_stock_picking(
+ return self._response_for_select_line(
picking, message=message.barcode_not_found()
)
def _select_lines_from_package(self, picking, selection_lines, package):
lines = selection_lines.filtered(lambda l: l.package_id == package)
if not lines:
- return self._response_for_selected_stock_picking(
+ return self._response_for_select_line(
picking,
message={
"message_type": "error",
@@ -319,13 +296,13 @@ def _select_lines_from_package(self, picking, selection_lines, package):
def _select_lines_from_product(self, picking, selection_lines, product):
message = self.actions_for("message")
if product.tracking in ("lot", "serial"):
- return self._response_for_selected_stock_picking(
+ return self._response_for_select_line(
picking, message=message.scan_lot_on_product_tracked_by_lot()
)
lines = selection_lines.filtered(lambda l: l.product_id == product)
if not lines:
- return self._response_for_selected_stock_picking(
+ return self._response_for_select_line(
picking,
message={
"message_type": "error",
@@ -343,7 +320,7 @@ def _select_lines_from_product(self, picking, selection_lines, product):
# a unit in another line. In both cases, we want the user to scan the
# package.
if packages and len({l.package_id for l in lines}) > 1:
- return self._response_for_selected_stock_picking(
+ return self._response_for_select_line(
picking, message=message.product_multiple_packages_scan_package()
)
elif packages:
@@ -357,7 +334,7 @@ def _select_lines_from_product(self, picking, selection_lines, product):
def _select_lines_from_lot(self, picking, selection_lines, lot):
lines = selection_lines.filtered(lambda l: l.lot_id == lot)
if not lines:
- return self._response_for_selected_stock_picking(
+ return self._response_for_select_line(
picking,
message={
"message_type": "error",
@@ -376,7 +353,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
# a unit in another line. In both cases, we want the user to scan the
# package.
if packages and len({l.package_id for l in lines}) > 1:
- return self._response_for_selected_stock_picking(
+ return self._response_for_select_line(
picking, message=message.lot_multiple_packages_scan_package()
)
elif packages:
@@ -390,7 +367,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
def _select_line_package(self, picking, selection_lines, package):
if not package:
message = self.actions_for("message")
- return self._response_for_selected_stock_picking(
+ return self._response_for_select_line(
picking, message=message.record_not_found()
)
return self._select_lines_from_package(picking, selection_lines, package)
@@ -398,7 +375,7 @@ def _select_line_package(self, picking, selection_lines, package):
def _select_line_move_line(self, picking, selection_lines, move_line):
if not move_line:
message = self.actions_for("message")
- return self._response_for_selected_stock_picking(
+ return self._response_for_select_line(
picking, message=message.record_not_found()
)
# normally, the client should sent only move lines out of packages, but
@@ -585,7 +562,7 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package):
{"result_package_id": package.id, "shopfloor_checkout_packed": True}
)
# go back to the screen to select the next lines to pack
- return self._response_for_selected_stock_picking(
+ return self._response_for_select_line(
picking,
message={
"message_type": "info",
@@ -967,12 +944,11 @@ def done(self, picking_id, confirmation=False):
},
)
picking.action_done()
- return self._response(
- next_state="select_document",
+ return self._response_for_select_document(
message={
"message_type": "success",
"message": _("Transfer {} done").format(picking.name),
- },
+ }
)
@@ -1219,7 +1195,9 @@ def select(self):
)
def scan_line(self):
- return self._response_schema(next_states={"select_line", "select_package"})
+ return self._response_schema(
+ next_states={"select_line", "select_package", "summary"}
+ )
def select_line(self):
return self.scan_line()
From a5a7d12e52de923829878697db198ea21d797aac Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 30 Mar 2020 14:29:12 +0200
Subject: [PATCH 171/940] checkout: go to summary when no more lines to select
When we want to scan or select a line and all lines already have been
processed.
---
shopfloor/services/checkout.py | 6 +++--
shopfloor/tests/test_checkout_scan_line.py | 25 ++++++++++++++++++++
shopfloor/tests/test_checkout_select_line.py | 20 ++++++++++++++++
3 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index da34175d40..26e12ab0c7 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -260,7 +260,8 @@ def scan_line(self, picking_id, barcode):
message = self.actions_for("message")
selection_lines = self._lines_to_pack(picking)
- # TODO handle no lines in selection go to summary
+ if not selection_lines:
+ return self._response_for_summary(picking)
package = search.package_from_scan(barcode)
if package:
@@ -410,7 +411,8 @@ def select_line(self, picking_id, package_id=None, move_line_id=None):
return self._response_stock_picking_does_not_exist()
selection_lines = self._lines_to_pack(picking)
- # TODO if no remaining lines, go to summary
+ if not selection_lines:
+ return self._response_for_summary(picking)
if package_id:
package = self.env["stock.quant.package"].browse(package_id).exists()
diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py
index 4344b8f8a9..64401d051d 100644
--- a/shopfloor/tests/test_checkout_scan_line.py
+++ b/shopfloor/tests/test_checkout_scan_line.py
@@ -274,3 +274,28 @@ def test_scan_line_error_lot_in_one_package_and_unit(self):
" packages, please scan a package.",
},
)
+
+ def test_scan_line_all_lines_done(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_b, 10)]
+ )
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ # set all lines as done
+ picking.move_line_ids.write(
+ {"qty_done": 10.0, "shopfloor_checkout_packed": True}
+ )
+ response = self.service.dispatch(
+ "scan_line",
+ params={
+ "picking_id": picking.id,
+ # the barcode doesn't matter as we have no
+ # lines to pack anymore
+ "barcode": self.product_a.barcode,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={"picking": self._stock_picking_data(picking)},
+ )
diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py
index 5db7c188a1..ef033ee1ce 100644
--- a/shopfloor/tests/test_checkout_select_line.py
+++ b/shopfloor/tests/test_checkout_select_line.py
@@ -90,3 +90,23 @@ def test_select_line_move_line_error_not_found(self):
"message": "The record you were working on does not exist anymore.",
},
)
+
+ def test_select_line_all_lines_done(self):
+ # set all lines as done
+ self.picking.move_line_ids.write(
+ {"qty_done": 10.0, "shopfloor_checkout_packed": True}
+ )
+ response = self.service.dispatch(
+ "select_line",
+ params={
+ "picking_id": self.picking.id,
+ # doesn't matter as all lines are done, we should be
+ # redirected to the summary
+ "package_id": self.picking.move_line_ids[0].package_id.id,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={"picking": self._stock_picking_data(self.picking)},
+ )
From de35d3b1acd1af26ca8e2f386a9db320a4c80828 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 1 Apr 2020 11:44:26 +0200
Subject: [PATCH 172/940] delivery: update endpoints specifications
We simplified the workflow for delivery, now everything is on a single
main screen. We only need other screens for the selection of
stock.picking and for confirmation of partials.
---
shopfloor/services/delivery.py | 175 +++++++++------------------------
1 file changed, 49 insertions(+), 126 deletions(-)
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 6db8933d8d..3a33be7b3f 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -17,6 +17,11 @@ class Delivery(Component):
* Products are moved to customer location as raw products
* Bin packed products are placed in new shipping package and shipped to customer
+ Every time a package, product or lot is scanned, the package level and move line
+ are set to done. When the last line is scanned, the transfer is set to done.
+ Data for the last transfer for which we have been scanning a line if it is not done.
+ When a transfer is scanned, it returns its data to be shown on the screen.
+
Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
"""
@@ -25,32 +30,33 @@ class Delivery(Component):
_usage = "delivery"
_description = __doc__
- # TODO we don't know yet if we have to select a destination package or a
- # destination location
- def xxx(self, barcode):
- return self._result()
+ def scan_deliver(self, barcode):
+ """Scan a stock picking or a package/product/lot
- # TODO we'll probably to add the selected destination package or location
- # id in every endpoint parameters and returns
- def scan_stock_picking(self, barcode):
- """Scan a stock picking or a package
+ When a stock picking is scanned and is partially or fully available, it
+ is returned to show its lines.
When a package is scanned, and has an available move line part of the
expected picking type, the package level is directly set to "done" and
the stock picking of the line is returned to work on its other lines.
- When a stock picking is scanned and is partially or fully available, it
- is returned to work on its lines.
+ If the barcode is a product or a product's packaging, the move lines
+ for this product are set to done. However, if the product is in more
+ than one package, a package barcode is requested, and if the product is
+ tracked by lot/serial, a lot is asked.
- When all the available move lines and package levels of the stock picking
- are done, the user is directed to the summary screen.
+ If the barcode is a lot, the mbarcode ove lines for this lot are set to
+ done. However, if the lot is in more than one package, a package
+ barcode is requested.
+
+ NOTE: see scan_line in the Checkout service.
+
+ When all the available move lines of the stock picking are done, the
+ stock picking is set to done.
Transitions:
- * select_source: error when scanning (stock picking not available, ...)
- * move_set_done: when a valid package or stock picking has been scanned
- and the stock picking still have lines / package levels not done
- * summary: when a valid package or stock picking has been scanned
- and all the lines of the stock picking are done
+ * deliver: always return here with the data for the last touched picking
+ or no picking if the picking has been set to done
"""
return self._response()
@@ -71,18 +77,18 @@ def select(self, picking_id):
Transitions:
* manual_selection: the selected stock picking is no longer valid
- * move_set_done: the selected stock picking has lines not done
- * summary: the selected stock picking has all lines done
+ * deliver: with information about the stock.picking
"""
return self._response()
def set_qty_done_pack(self, picking_id, package_id):
"""Set a package to "Done"
+ When all the available move lines of the stock picking are done, the
+ stock picking is set to done.
+
Transitions:
- * move_set_done: error when setting done, or success but the stock
- picking has other lines to set done
- * summary: all the lines of the stock picking are now done
+ * deliver: always return here with updated data
"""
return self._response()
@@ -92,42 +98,11 @@ def set_qty_done_line(self, picking_id, line_id):
Should be called only for lines of raw products, /set_qty_done_pack
must be used for lines that move a package.
- Transitions:
- * move_set_done: error when setting done, or success but the stock
- picking has other lines to set done
- * summary: all the lines of the stock picking are now done
- """
- return self._response()
-
- def scan_line(self, picking_id, barcode):
- """Set a move line or package to "Done" from a barcode
-
- If the barcode is a package in the picking and is available, it
- sets it to done.
-
- If the barcode is a product or a product's packaging, the move lines
- for this product are set to done. However, if the product is in more
- than one package, a package barcode is requested, and if the product is
- tracked by lot/serial, a lot is asked.
-
- If the barcode is a lot, the mbarcode ove lines for this lot are set to
- done. However, if the lot is in more than one package, a package
- barcode is requested.
-
- NOTE: see scan_line in the Checkout service.
+ When all the available move lines of the stock picking are done, the
+ stock picking is set to done.
Transitions:
- * move_set_done: error when setting done, or success but the stock
- picking has other lines to set done
- * summary: all the lines of the stock picking are now done
- """
- return self._response()
-
- def summary(self, picking_id):
- """Return data for the summary screen
-
- Transitions:
- * summary
+ * deliver: always return here with updated data
"""
return self._response()
@@ -135,7 +110,7 @@ def reset_qty_done_pack(self, picking_id, package_id):
"""Remove "Done" on a package
Transitions:
- * summary: return back to this state
+ * deliver: always return here with updated data
"""
return self._response()
@@ -146,7 +121,7 @@ def reset_qty_done_line(self, picking_id, line_id):
must be used for lines that move a package.
Transitions:
- * summary: return back to this state
+ * deliver: always return here with updated data
"""
return self._response()
@@ -154,19 +129,8 @@ def done(self, picking_id):
"""Set the stock picking to done
Transitions:
- * summary: error during action
- * select_source: stock picking was set to done, user can work on the
- next stock picking
- """
- return self._response()
-
- def back_to_move_set_done(self, picking_id):
- """Allow to return to the "move_set_done" state with refreshed data
-
- Transitions:
- * move_set_done: return to this state when not all lines of the
- stock picking are done
- * summary: return back to this state when all lines are done
+ * deliver: error during action
+ * confirm_done: when not all lines of the stock.picking are done
"""
return self._response()
@@ -178,11 +142,7 @@ class ShopfloorDeliveryValidator(Component):
_name = "shopfloor.delivery.validator"
_usage = "delivery.validator"
- # TODO
- def xxx(self):
- return {}
-
- def scan_stock_picking(self):
+ def scan_deliver(self):
return {"barcode": {"required": True, "type": "string"}}
def list_stock_picking(self):
@@ -203,15 +163,6 @@ def set_qty_done_line(self):
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
}
- def scan_line(self):
- return {
- "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
- "barcode": {"required": True, "type": "string"},
- }
-
- def summary(self):
- return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}}
-
def reset_qty_done_pack(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
@@ -227,9 +178,6 @@ def reset_qty_done_line(self):
def done(self):
return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}}
- def back_to_move_set_done(self):
- return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}}
-
class ShopfloorDeliveryValidatorResponse(Component):
"""Validators for the Delivery endpoints responses"""
@@ -238,7 +186,7 @@ class ShopfloorDeliveryValidatorResponse(Component):
_name = "shopfloor.delivery.validator.response"
_usage = "delivery.validator.response"
- _start_state = "start"
+ _start_state = "deliver"
def _states(self):
"""List of possible next states
@@ -247,17 +195,13 @@ def _states(self):
to the next state.
"""
return {
- "start": {},
- "select_source": self._schema_select_source,
+ "deliver": self._schema_deliver,
"manual_selection": self._schema_selection_list,
- "move_set_done": self._schema_stock_picking_details,
- "summary": self._schema_stock_picking_details,
- "confirm_summary": self._schema_stock_picking_details,
+ "confirm_done": self._schema_deliver,
}
- # TODO add the selected dest. package or location in returns (to keep it stateless)
@property
- def _schema_stock_picking_details(self):
+ def _schema_deliver(self):
schema = self.schemas().picking()
schema.update(
{
@@ -267,7 +211,7 @@ def _schema_stock_picking_details(self):
}
}
)
- return {"picking": {"type": "dict", "schema": schema}}
+ return {"picking": {"type": "dict", "required": False, "schema": schema}}
@property
def _schema_selection_list(self):
@@ -278,47 +222,26 @@ def _schema_selection_list(self):
}
}
- @property
- def _schema_select_source(self):
- # TODO we don't know yet if we want to show the dest. location or package
- return {}
-
- def xxx(self):
- return self._response_schema(next_states={"start", "select_source"})
-
- def scan_stock_picking(self):
- return self._response_schema(
- next_states={"select_source", "move_set_done", "summary"}
- )
+ def scan_deliver(self):
+ return self._response_schema(next_states={"deliver"})
def list_stock_picking(self):
return self._response_schema(next_states={"manual_selection"})
def select(self):
- return self._response_schema(
- next_states={"manual_selection", "move_set_done", "summary"}
- )
+ return self._response_schema(next_states={"deliver"})
def set_qty_done_pack(self):
- return self._response_schema(next_states={"move_set_done", "summary"})
+ return self._response_schema(next_states={"deliver"})
def set_qty_done_line(self):
- return self._response_schema(next_states={"move_set_done", "summary"})
-
- def scan_line(self):
- return self._response_schema(next_states={"move_set_done", "summary"})
-
- def summary(self):
- return self._response_schema(next_states={"summary"})
+ return self._response_schema(next_states={"deliver"})
def reset_qty_done_pack(self):
- return self._response_schema(next_states={"summary"})
+ return self._response_schema(next_states={"deliver"})
def reset_qty_done_line(self):
- return self._response_schema(next_states={"summary"})
+ return self._response_schema(next_states={"deliver"})
def done(self):
- return self._response_schema(next_states={"confirm_summary", "start"})
-
- def back_to_move_set_done(self):
- return self._response_schema(next_states={"move_set_done", "summary"})
+ return self._response_schema(next_states={"deliver", "confirm_done"})
From 58f51548044772deeac72ea67b3d6eff89355be3 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 1 Apr 2020 11:55:13 +0200
Subject: [PATCH 173/940] checkout: fix parameters for
_response_for_select_document
introduced in 6ba3915
---
shopfloor/services/checkout.py | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 26e12ab0c7..bb9db670c7 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -115,7 +115,7 @@ def _select_picking(self, picking, state_for_error):
message=message.stock_picking_not_available(picking)
)
return self._response_for_select_document(
- picking, message=message.stock_picking_not_available(picking)
+ message=message.stock_picking_not_available(picking)
)
return self._response_for_select_line(picking)
@@ -135,8 +135,7 @@ def _response_for_summary(self, picking, need_confirm=False, message=None):
message=message,
)
- def _response_for_select_document(self, picking, message=None):
- message = self.actions_for("message")
+ def _response_for_select_document(self, message=None):
return self._response(next_state="select_document", message=message)
def _data_for_stock_picking(self, picking):
From a9c737abafe45634efd1a11c18f56e1a24bed919 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 10 Apr 2020 14:51:50 +0200
Subject: [PATCH 174/940] frontend: adapt data to backend changes checkout
---
shopfloor/services/cluster_picking.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index a81634f37d..41da540a84 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -266,6 +266,7 @@ def _data_for_next_move_line(self, picking_batch):
return self._data_move_line(remaining_lines[0])
def _data_move_line(self, line):
+ # TODO: use shopfloor.data.action.move_line
picking = line.picking_id
batch = picking.batch_id
product = line.product_id
From 9f2581bb6efe6e65aad84efdda3fab737c60c48c Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 24 Apr 2020 13:33:22 +0200
Subject: [PATCH 175/940] Remove dead code
---
shopfloor/services/checkout.py | 7 -------
1 file changed, 7 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index bb9db670c7..76acbfae7c 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -898,13 +898,6 @@ def remove_package(self, picking_id, package_id):
)
return self._response_for_summary(picking)
- @staticmethod
- def _filter_lines_(move_line):
- return (
- move_line.qty_done == move_line.product_uom_qty
- and move_line.shopfloor_checkout_packed
- )
-
def done(self, picking_id, confirmation=False):
"""Set the moves as done
From 9815b7185b91218aef2e06357dc9b3eecc237980 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 24 Apr 2020 09:45:42 +0200
Subject: [PATCH 176/940] backend: fix summary endpoints
---
shopfloor/services/checkout.py | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 76acbfae7c..883ea54efb 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -131,25 +131,25 @@ def _response_for_select_line(self, picking, message=None):
def _response_for_summary(self, picking, need_confirm=False, message=None):
return self._response(
next_state="summary" if not need_confirm else "confirm_done",
- data={"picking": self._data_for_stock_picking(picking)},
+ data={"picking": self._data_for_stock_picking(picking, packed=True)},
message=message,
)
def _response_for_select_document(self, message=None):
return self._response(next_state="select_document", message=message)
- def _data_for_stock_picking(self, picking):
+ def _data_for_stock_picking(self, picking, packed=False):
data_struct = self.actions_for("data")
data = data_struct.picking_summary(picking)
+ line_picker = self._lines_packed if packed else self._lines_to_pack
data.update(
- {
- "move_lines": [
- data_struct.move_line(ml) for ml in self._lines_to_pack(picking)
- ]
- }
+ {"move_lines": [data_struct.move_line(ml) for ml in line_picker(picking)]}
)
return data
+ def _lines_packed(self, picking):
+ return picking.move_line_ids.filtered(self._filter_lines_packed)
+
def _lines_to_pack(self, picking):
return picking.move_line_ids.filtered(self._filter_lines_unpacked)
@@ -1211,7 +1211,7 @@ def scan_package_action(self):
)
def new_package(self):
- return self._response_schema(next_states={"select_line"})
+ return self._response_schema(next_states={"select_line", "summary"})
def list_dest_package(self):
return self._response_schema(
From 35886a795ca877e4261c2c4879375b8be42f3da3 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 29 Apr 2020 10:11:54 +0200
Subject: [PATCH 177/940] backend: add checkout endpoint no_package
---
shopfloor/services/checkout.py | 29 +++++++++++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_checkout_no_package.py | 56 +++++++++++++++++++++
3 files changed, 86 insertions(+)
create mode 100644 shopfloor/tests/test_checkout_no_package.py
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 883ea54efb..6b8080d510 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -676,6 +676,29 @@ def new_package(self, picking_id, selected_line_ids):
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
return self._create_and_assign_new_packaging(picking, selected_lines)
+ def no_package(self, picking_id, selected_line_ids):
+ """Process all selected lines without any package.
+
+ Selected lines are move lines in the list of ``move_line_ids`` where
+ ``qty_done`` > 0 and have no destination package
+ (shopfloor_checkout_packed is False).
+
+ Transitions:
+ * select_line: goes back to selection of lines to work on next lines
+ """
+ picking = self.env["stock.picking"].browse(picking_id)
+ if not picking.exists():
+ return self._response_stock_picking_does_not_exist()
+ selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
+ selected_lines.write({"shopfloor_checkout_packed": True})
+ return self._response_for_select_line(
+ picking,
+ message={
+ "message_type": "info",
+ "message": _("Product(s) processed as raw product(s)"),
+ },
+ )
+
def list_dest_package(self, picking_id, selected_line_ids):
"""Return a list of packages the user can select for the lines
@@ -1030,6 +1053,9 @@ def new_package(self):
},
}
+ def no_package(self):
+ return self.new_package()
+
def list_dest_package(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
@@ -1213,6 +1239,9 @@ def scan_package_action(self):
def new_package(self):
return self._response_schema(next_states={"select_line", "summary"})
+ def no_package(self):
+ return self.new_package()
+
def list_dest_package(self):
return self._response_schema(
next_states={"select_dest_package", "select_package"}
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 8911b00f82..87e4ace1c2 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -19,6 +19,7 @@
from . import test_checkout_set_qty
from . import test_checkout_scan_package_action
from . import test_checkout_new_package
+from . import test_checkout_no_package
from . import test_checkout_list_package
from . import test_checkout_summary
from . import test_checkout_change_packaging
diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py
new file mode 100644
index 0000000000..9cdfd567c9
--- /dev/null
+++ b/shopfloor/tests/test_checkout_no_package.py
@@ -0,0 +1,56 @@
+from .test_checkout_base import CheckoutCommonCase
+from .test_checkout_select_package_base import CheckoutSelectPackageMixin
+
+
+class CheckoutNoPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
+ def test_no_package_ok(self):
+ picking = self._create_picking(
+ lines=[
+ (self.product_a, 10),
+ (self.product_b, 10),
+ (self.product_c, 10),
+ (self.product_d, 10),
+ ]
+ )
+ pack1_moves = picking.move_lines[:3]
+ pack2_moves = picking.move_lines[3:]
+ # put in 2 packs, for this test, we'll work on pack1
+ self._fill_stock_for_moves(pack1_moves)
+ self._fill_stock_for_moves(pack2_moves)
+ picking.action_assign()
+
+ move_line1, move_line2, move_line3 = pack1_moves.move_line_ids
+ selected_lines = move_line1 + move_line2
+
+ # we'll put only the first 2 lines (product A and B) w/ no package
+ move_line1.qty_done = move_line1.product_uom_qty
+ move_line2.qty_done = move_line2.product_uom_qty
+ move_line3.qty_done = 0
+
+ response = self.service.dispatch(
+ "no_package",
+ params={"picking_id": picking.id, "selected_line_ids": selected_lines.ids},
+ )
+
+ self.assertRecordValues(
+ move_line1,
+ [{"result_package_id": False, "shopfloor_checkout_packed": True}],
+ )
+ self.assertRecordValues(
+ move_line2,
+ [{"result_package_id": False, "shopfloor_checkout_packed": True}],
+ )
+ self.assertRecordValues(
+ move_line3,
+ [{"result_package_id": False, "shopfloor_checkout_packed": False}],
+ )
+ self.assert_response(
+ response,
+ # go pack to the screen to select lines to put in packages
+ next_state="select_line",
+ data={"picking": self._stock_picking_data(picking)},
+ message={
+ "message_type": "info",
+ "message": "Product(s) processed as raw product(s)",
+ },
+ )
From 778c54059f1ba3e13db9697d3d532765da5bc459 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 29 Apr 2020 11:09:58 +0200
Subject: [PATCH 178/940] backend: checkout summary load only done lines
---
shopfloor/models/stock_move_line.py | 2 +-
shopfloor/services/checkout.py | 47 ++++++++++---------
shopfloor/tests/test_checkout_base.py | 4 +-
.../tests/test_checkout_change_packaging.py | 8 ++--
shopfloor/tests/test_checkout_done.py | 14 +++---
shopfloor/tests/test_checkout_list_package.py | 9 ++--
shopfloor/tests/test_checkout_new_package.py | 6 +--
shopfloor/tests/test_checkout_no_package.py | 8 ++--
.../tests/test_checkout_remove_package.py | 16 +++----
shopfloor/tests/test_checkout_scan_line.py | 6 +--
.../test_checkout_scan_package_action.py | 24 +++++-----
shopfloor/tests/test_checkout_select_line.py | 4 +-
shopfloor/tests/test_checkout_summary.py | 2 +-
13 files changed, 72 insertions(+), 78 deletions(-)
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index 0d772accc9..0ee951644f 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -12,7 +12,7 @@ class StockMoveLine(models.Model):
help="Technical field. "
"Indicates if a the move has been postponed in a process.",
)
- shopfloor_checkout_packed = fields.Boolean(default=False)
+ shopfloor_checkout_done = fields.Boolean(default=False)
# we search lines based on their location in some workflows
location_id = fields.Many2one(index=True)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 6b8080d510..79cd7470a4 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -23,8 +23,8 @@ class Checkout(Component):
5) Products are not packed (e.g. raw products) and we create new packages
6) Products are not packed (e.g. raw products) and we do not create packages
- A new flag ``shopfloor_checkout_packed`` on move lines allows to track which
- lines have been put in a package.
+ A new flag ``shopfloor_checkout_done`` on move lines allows to track which
+ lines have been checked out (can be with or without package).
Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
"""
@@ -120,7 +120,7 @@ def _select_picking(self, picking, state_for_error):
return self._response_for_select_line(picking)
def _response_for_select_line(self, picking, message=None):
- if all(line.shopfloor_checkout_packed for line in picking.move_line_ids):
+ if all(line.shopfloor_checkout_done for line in picking.move_line_ids):
return self._response_for_summary(picking, message=message)
return self._response(
next_state="select_line",
@@ -131,24 +131,24 @@ def _response_for_select_line(self, picking, message=None):
def _response_for_summary(self, picking, need_confirm=False, message=None):
return self._response(
next_state="summary" if not need_confirm else "confirm_done",
- data={"picking": self._data_for_stock_picking(picking, packed=True)},
+ data={"picking": self._data_for_stock_picking(picking, done=True)},
message=message,
)
def _response_for_select_document(self, message=None):
return self._response(next_state="select_document", message=message)
- def _data_for_stock_picking(self, picking, packed=False):
+ def _data_for_stock_picking(self, picking, done=False):
data_struct = self.actions_for("data")
data = data_struct.picking_summary(picking)
- line_picker = self._lines_packed if packed else self._lines_to_pack
+ line_picker = self._lines_checkout_done if done else self._lines_to_pack
data.update(
{"move_lines": [data_struct.move_line(ml) for ml in line_picker(picking)]}
)
return data
- def _lines_packed(self, picking):
- return picking.move_line_ids.filtered(self._filter_lines_packed)
+ def _lines_checkout_done(self, picking):
+ return picking.move_line_ids.filtered(self._filter_lines_checkout_done)
def _lines_to_pack(self, picking):
return picking.move_line_ids.filtered(self._filter_lines_unpacked)
@@ -221,7 +221,7 @@ def _response_for_select_package(self, lines, message=None):
def _select_lines(self, lines):
for line in lines:
- if line.shopfloor_checkout_packed:
+ if line.shopfloor_checkout_done:
continue
line.qty_done = line.product_uom_qty
@@ -230,7 +230,7 @@ def _select_lines(self, lines):
self._deselect_lines(other_lines)
def _deselect_lines(self, lines):
- lines.filtered(lambda l: not l.shopfloor_checkout_packed).qty_done = 0
+ lines.filtered(lambda l: not l.shopfloor_checkout_done).qty_done = 0
def scan_line(self, picking_id, barcode):
"""Scan move lines of the stock picking
@@ -522,15 +522,15 @@ def _switch_line_qty_done(self, picking, selected_lines, switch_lines):
@staticmethod
def _filter_lines_unpacked(move_line):
- return move_line.qty_done == 0 and not move_line.shopfloor_checkout_packed
+ return move_line.qty_done == 0 and not move_line.shopfloor_checkout_done
@staticmethod
def _filter_lines_to_pack(move_line):
- return move_line.qty_done > 0 and not move_line.shopfloor_checkout_packed
+ return move_line.qty_done > 0 and not move_line.shopfloor_checkout_done
@staticmethod
- def _filter_lines_packed(move_line):
- return move_line.qty_done > 0 and move_line.shopfloor_checkout_packed
+ def _filter_lines_checkout_done(move_line):
+ return move_line.qty_done > 0 and move_line.shopfloor_checkout_done
def _is_package_allowed(self, picking, package):
existing_packages = picking.mapped("move_line_ids.result_package_id")
@@ -560,7 +560,7 @@ def _put_lines_in_package(self, picking, selected_lines, package):
def _put_lines_in_allowed_package(self, picking, selected_lines, package):
lines_to_pack = selected_lines.filtered(self._filter_lines_to_pack)
lines_to_pack.write(
- {"result_package_id": package.id, "shopfloor_checkout_packed": True}
+ {"result_package_id": package.id, "shopfloor_checkout_done": True}
)
# go back to the screen to select the next lines to pack
return self._response_for_select_line(
@@ -610,7 +610,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
Lines to pack are move lines in the list of ``selected_line_ids``
where ``qty_done`` > 0 and have not been packed yet
- (``shopfloor_checkout_packed is False``).
+ (``shopfloor_checkout_done is False``).
Transitions:
* select_package: when a product or lot is scanned to select/deselect,
@@ -665,7 +665,7 @@ def new_package(self, picking_id, selected_line_ids):
Selected lines are move lines in the list of ``move_line_ids`` where
``qty_done`` > 0 and have no destination package
- (shopfloor_checkout_packed is False).
+ (shopfloor_checkout_done is False).
Transitions:
* select_line: goes back to selection of lines to work on next lines
@@ -681,7 +681,7 @@ def no_package(self, picking_id, selected_line_ids):
Selected lines are move lines in the list of ``move_line_ids`` where
``qty_done`` > 0 and have no destination package
- (shopfloor_checkout_packed is False).
+ (shopfloor_checkout_done is False).
Transitions:
* select_line: goes back to selection of lines to work on next lines
@@ -690,7 +690,7 @@ def no_package(self, picking_id, selected_line_ids):
if not picking.exists():
return self._response_stock_picking_does_not_exist()
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
- selected_lines.write({"shopfloor_checkout_packed": True})
+ selected_lines.write({"shopfloor_checkout_done": True})
return self._response_for_select_line(
picking,
message={
@@ -893,7 +893,7 @@ def remove_package(self, picking_id, package_id):
All the move lines with the package as ``result_package_id`` have their
``result_package_id`` reset to the source package (default odoo behavior)
and their ``qty_done`` set to 0.
- It flags ``shopfloor_checkout_packed`` to False so they have to be packed again.
+ It flags ``shopfloor_checkout_done`` to False so they have to be packed again.
Transitions:
* summary
@@ -909,14 +909,15 @@ def remove_package(self, picking_id, package_id):
picking, message=message.record_not_found()
)
move_lines = picking.move_line_ids.filtered(
- lambda l: self._filter_lines_packed(l) and l.result_package_id == package
+ lambda l: self._filter_lines_checkout_done(l)
+ and l.result_package_id == package
)
for move_line in move_lines:
move_line.write(
{
"qty_done": 0,
"result_package_id": move_line.package_id,
- "shopfloor_checkout_packed": False,
+ "shopfloor_checkout_done": False,
}
)
return self._response_for_summary(picking)
@@ -949,7 +950,7 @@ def done(self, picking_id, confirmation=False):
),
},
)
- elif not all(line.shopfloor_checkout_packed for line in lines):
+ elif not all(line.shopfloor_checkout_done for line in lines):
return self._response_for_summary(
picking,
need_confirm=True,
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index cbf348b246..1f09b9d0a3 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -17,8 +17,8 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="checkout")
- def _stock_picking_data(self, picking):
- return self.service._data_for_stock_picking(picking)
+ def _stock_picking_data(self, picking, **kw):
+ return self.service._data_for_stock_picking(picking, **kw)
# we test the methods that structure data in test_actions_data.py
def _picking_summary_data(self, picking):
diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py
index d532830a1b..3d06fc48ad 100644
--- a/shopfloor/tests/test_checkout_change_packaging.py
+++ b/shopfloor/tests/test_checkout_change_packaging.py
@@ -69,7 +69,7 @@ def test_list_packaging_error_package_not_found(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking)},
+ data={"picking": self._stock_picking_data(self.picking, done=True)},
message={
"message_type": "error",
"message": "The record you were working on does not exist anymore.",
@@ -91,7 +91,7 @@ def test_set_packaging_ok(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking)},
+ data={"picking": self._stock_picking_data(self.picking, done=True)},
message={
"message_type": "success",
"message": "Packaging changed on package {}".format(self.package.name),
@@ -110,7 +110,7 @@ def test_set_packaging_error_package_not_found(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking)},
+ data={"picking": self._stock_picking_data(self.picking, done=True)},
message={
"message_type": "error",
"message": "The record you were working on does not exist anymore.",
@@ -129,7 +129,7 @@ def test_set_packaging_error_packaging_not_found(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking)},
+ data={"picking": self._stock_picking_data(self.picking, done=True)},
message={
"message_type": "error",
"message": "The record you were working on does not exist anymore.",
diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py
index 373fb2727e..f47db4fd6c 100644
--- a/shopfloor/tests/test_checkout_done.py
+++ b/shopfloor/tests/test_checkout_done.py
@@ -7,7 +7,7 @@ def test_done_ok(self):
self._fill_stock_for_moves(picking.move_lines, in_package=True)
picking.action_assign()
# line is done
- picking.move_line_ids.write({"qty_done": 10, "shopfloor_checkout_packed": True})
+ picking.move_line_ids.write({"qty_done": 10, "shopfloor_checkout_done": True})
response = self.service.dispatch("done", params={"picking_id": picking.id})
self.assertRecordValues(picking, [{"state": "done"}])
@@ -33,8 +33,8 @@ def setUpClass(cls):
picking.action_assign()
cls.line1 = picking.move_line_ids[0]
cls.line2 = picking.move_line_ids[1]
- cls.line1.write({"qty_done": 10, "shopfloor_checkout_packed": True})
- cls.line2.write({"qty_done": 2, "shopfloor_checkout_packed": True})
+ cls.line1.write({"qty_done": 10, "shopfloor_checkout_done": True})
+ cls.line2.write({"qty_done": 2, "shopfloor_checkout_done": True})
def test_done_partial(self):
# line is done
@@ -45,7 +45,7 @@ def test_done_partial(self):
self.assert_response(
response,
next_state="confirm_done",
- data={"picking": self._stock_picking_data(self.picking)},
+ data={"picking": self._stock_picking_data(self.picking, done=True)},
message={
"message_type": "warning",
"message": "Not all lines have been processed, do"
@@ -87,11 +87,11 @@ def setUpClass(cls):
cls.line1.write(
{
"qty_done": 10,
- "shopfloor_checkout_packed": True,
+ "shopfloor_checkout_done": True,
"result_package_id": cls.package.id,
}
)
- cls.line2.write({"qty_done": 10, "shopfloor_checkout_packed": False})
+ cls.line2.write({"qty_done": 10, "shopfloor_checkout_done": False})
def test_done_partial(self):
# line is done
@@ -102,7 +102,7 @@ def test_done_partial(self):
self.assert_response(
response,
next_state="confirm_done",
- data={"picking": self._stock_picking_data(self.picking)},
+ data={"picking": self._stock_picking_data(self.picking, done=True)},
message={
"message_type": "warning",
"message": "Remaining raw product not packed, proceed anyway?",
diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py
index e008422ac2..a17bcb9d91 100644
--- a/shopfloor/tests/test_checkout_list_package.py
+++ b/shopfloor/tests/test_checkout_list_package.py
@@ -130,17 +130,14 @@ def _assert_package_set(self, response):
[
{
"result_package_id": self.package.id,
- "shopfloor_checkout_packed": True,
+ "shopfloor_checkout_done": True,
},
{
"result_package_id": self.package.id,
- "shopfloor_checkout_packed": True,
+ "shopfloor_checkout_done": True,
},
# qty_done was zero so we don't set it as packed
- {
- "result_package_id": self.pack1.id,
- "shopfloor_checkout_packed": False,
- },
+ {"result_package_id": self.pack1.id, "shopfloor_checkout_done": False},
],
)
self.assert_response(
diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py
index bd62d52c49..ab720ce5ae 100644
--- a/shopfloor/tests/test_checkout_new_package.py
+++ b/shopfloor/tests/test_checkout_new_package.py
@@ -38,17 +38,17 @@ def test_new_package_ok(self):
self.assertRecordValues(
move_line1,
- [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}],
+ [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}],
)
self.assertRecordValues(
move_line2,
- [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}],
+ [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}],
)
self.assertRecordValues(
move_line3,
# qty_done was zero so we don't set it as packed and it remains in
# the same package
- [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}],
+ [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}],
)
self.assert_response(
response,
diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py
index 9cdfd567c9..2da2f732a6 100644
--- a/shopfloor/tests/test_checkout_no_package.py
+++ b/shopfloor/tests/test_checkout_no_package.py
@@ -33,16 +33,14 @@ def test_no_package_ok(self):
)
self.assertRecordValues(
- move_line1,
- [{"result_package_id": False, "shopfloor_checkout_packed": True}],
+ move_line1, [{"result_package_id": False, "shopfloor_checkout_done": True}],
)
self.assertRecordValues(
- move_line2,
- [{"result_package_id": False, "shopfloor_checkout_packed": True}],
+ move_line2, [{"result_package_id": False, "shopfloor_checkout_done": True}],
)
self.assertRecordValues(
move_line3,
- [{"result_package_id": False, "shopfloor_checkout_packed": False}],
+ [{"result_package_id": False, "shopfloor_checkout_done": False}],
)
self.assert_response(
response,
diff --git a/shopfloor/tests/test_checkout_remove_package.py b/shopfloor/tests/test_checkout_remove_package.py
index 9a2d407118..09c8e73976 100644
--- a/shopfloor/tests/test_checkout_remove_package.py
+++ b/shopfloor/tests/test_checkout_remove_package.py
@@ -34,7 +34,7 @@ def test_remove_package_ok(self):
{
"qty_done": 10,
"result_package_id": new_package.id,
- "shopfloor_checkout_packed": True,
+ "shopfloor_checkout_done": True,
}
)
new_package2 = self.env["stock.quant.package"].create({})
@@ -42,7 +42,7 @@ def test_remove_package_ok(self):
{
"qty_done": 10,
"result_package_id": new_package2.id,
- "shopfloor_checkout_packed": True,
+ "shopfloor_checkout_done": True,
}
)
@@ -59,25 +59,25 @@ def test_remove_package_ok(self):
"qty_done": 0,
# reset to origin package
"result_package_id": pack1_lines.mapped("package_id").id,
- "shopfloor_checkout_packed": False,
+ "shopfloor_checkout_done": False,
},
{
"qty_done": 0,
# reset to origin package
"result_package_id": pack1_lines.mapped("package_id").id,
- "shopfloor_checkout_packed": False,
+ "shopfloor_checkout_done": False,
},
{
"qty_done": 0,
# result to an empty package (raw product)
"result_package_id": False,
- "shopfloor_checkout_packed": False,
+ "shopfloor_checkout_done": False,
},
# different package, leave untouched
{
"qty_done": 10,
"result_package_id": new_package2.id,
- "shopfloor_checkout_packed": True,
+ "shopfloor_checkout_done": True,
},
],
)
@@ -85,7 +85,7 @@ def test_remove_package_ok(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(picking)},
+ data={"picking": self._stock_picking_data(picking, done=True)},
)
def test_remove_package_error_package_not_found(self):
@@ -96,7 +96,7 @@ def test_remove_package_error_package_not_found(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking)},
+ data={"picking": self._stock_picking_data(self.picking, done=True)},
message={
"message_type": "error",
"message": "The record you were working on does not exist anymore.",
diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py
index 64401d051d..8163bcdfcd 100644
--- a/shopfloor/tests/test_checkout_scan_line.py
+++ b/shopfloor/tests/test_checkout_scan_line.py
@@ -282,9 +282,7 @@ def test_scan_line_all_lines_done(self):
self._fill_stock_for_moves(picking.move_lines, in_package=True)
picking.action_assign()
# set all lines as done
- picking.move_line_ids.write(
- {"qty_done": 10.0, "shopfloor_checkout_packed": True}
- )
+ picking.move_line_ids.write({"qty_done": 10.0, "shopfloor_checkout_done": True})
response = self.service.dispatch(
"scan_line",
params={
@@ -297,5 +295,5 @@ def test_scan_line_all_lines_done(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(picking)},
+ data={"picking": self._stock_picking_data(picking, done=True)},
)
diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py
index 1af133148d..0c293e308f 100644
--- a/shopfloor/tests/test_checkout_scan_package_action.py
+++ b/shopfloor/tests/test_checkout_scan_package_action.py
@@ -148,16 +148,16 @@ def test_scan_package_action_scan_package_keep_source_package_ok(self):
self.assertRecordValues(
move_line1,
- [{"result_package_id": pack1.id, "shopfloor_checkout_packed": True}],
+ [{"result_package_id": pack1.id, "shopfloor_checkout_done": True}],
)
self.assertRecordValues(
move_line2,
- [{"result_package_id": pack1.id, "shopfloor_checkout_packed": True}],
+ [{"result_package_id": pack1.id, "shopfloor_checkout_done": True}],
)
self.assertRecordValues(
move_line3,
# qty_done was zero so we don't set it as packed
- [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}],
+ [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}],
)
self.assert_response(
response,
@@ -195,7 +195,7 @@ def test_scan_package_action_scan_package_error_invalid(self):
# the result package must remain identical, so equal to the
# source package
"result_package_id": selected_line.package_id.id,
- "shopfloor_checkout_packed": False,
+ "shopfloor_checkout_done": False,
}
],
)
@@ -229,7 +229,7 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self):
# assume that product d was already put in a package,
# we must be able to put the lines of pack1 inside the same
pack2_moves.move_line_ids.write(
- {"result_package_id": package.id, "shopfloor_checkout_packed": True}
+ {"result_package_id": package.id, "shopfloor_checkout_done": True}
)
selected_lines = pack1_moves.move_line_ids
@@ -249,9 +249,9 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self):
self.assertRecordValues(
selected_lines,
[
- {"result_package_id": package.id, "shopfloor_checkout_packed": True},
- {"result_package_id": package.id, "shopfloor_checkout_packed": True},
- {"result_package_id": package.id, "shopfloor_checkout_packed": True},
+ {"result_package_id": package.id, "shopfloor_checkout_done": True},
+ {"result_package_id": package.id, "shopfloor_checkout_done": True},
+ {"result_package_id": package.id, "shopfloor_checkout_done": True},
],
)
@@ -259,7 +259,7 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self):
response,
# all the lines are packed, so we expect to go the summary screen
next_state="summary",
- data={"picking": self._stock_picking_data(picking)},
+ data={"picking": self._stock_picking_data(picking, done=True)},
message={
"message_type": "info",
"message": "Product(s) packed in {}".format(package.name),
@@ -328,17 +328,17 @@ def test_scan_package_action_scan_packaging_ok(self):
self.assertRecordValues(
move_line1,
- [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}],
+ [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}],
)
self.assertRecordValues(
move_line2,
- [{"result_package_id": new_package.id, "shopfloor_checkout_packed": True}],
+ [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}],
)
self.assertRecordValues(
move_line3,
# qty_done was zero so we don't set it as packed and it remains in
# the same package
- [{"result_package_id": pack1.id, "shopfloor_checkout_packed": False}],
+ [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}],
)
self.assert_response(
response,
diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py
index ef033ee1ce..800daf56de 100644
--- a/shopfloor/tests/test_checkout_select_line.py
+++ b/shopfloor/tests/test_checkout_select_line.py
@@ -94,7 +94,7 @@ def test_select_line_move_line_error_not_found(self):
def test_select_line_all_lines_done(self):
# set all lines as done
self.picking.move_line_ids.write(
- {"qty_done": 10.0, "shopfloor_checkout_packed": True}
+ {"qty_done": 10.0, "shopfloor_checkout_done": True}
)
response = self.service.dispatch(
"select_line",
@@ -108,5 +108,5 @@ def test_select_line_all_lines_done(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking)},
+ data={"picking": self._stock_picking_data(self.picking, done=True)},
)
diff --git a/shopfloor/tests/test_checkout_summary.py b/shopfloor/tests/test_checkout_summary.py
index c58803c11c..9b0149335d 100644
--- a/shopfloor/tests/test_checkout_summary.py
+++ b/shopfloor/tests/test_checkout_summary.py
@@ -16,5 +16,5 @@ def test_summary_ok(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(picking)},
+ data={"picking": self._stock_picking_data(picking, done=True)},
)
From 965ec6b2d2212d49f229936bb47cf63905944303 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 29 Apr 2020 12:12:25 +0200
Subject: [PATCH 179/940] backend: replace 'remove_package' w/ 'cancel_line'
We can checkout raw products and we might want to cancel them
as we were able to do w/ packages by removing them.
'remove_package' is renamed to 'cancel_line' and accept
both a package_id or a line_id.
---
shopfloor/services/checkout.py | 58 +++++++++++++------
shopfloor/tests/__init__.py | 2 +-
...ackage.py => test_checkout_cancel_line.py} | 45 ++++++++++++--
3 files changed, 82 insertions(+), 23 deletions(-)
rename shopfloor/tests/{test_checkout_remove_package.py => test_checkout_cancel_line.py} (70%)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 79cd7470a4..e8c59d4c08 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -887,13 +887,18 @@ def set_packaging(self, picking_id, package_id, packaging_id):
},
)
- def remove_package(self, picking_id, package_id):
- """Remove destination package from move lines and set qty done to 0
+ def cancel_line(self, picking_id, package_id=None, line_id=None):
+ """Cancel work done on given line or package.
+
+ If package, remove destination package from lines and set qty done to 0.
+ If line is a raw product, set qty done to 0.
All the move lines with the package as ``result_package_id`` have their
``result_package_id`` reset to the source package (default odoo behavior)
and their ``qty_done`` set to 0.
- It flags ``shopfloor_checkout_done`` to False so they have to be packed again.
+
+ It flags ``shopfloor_checkout_done`` to False
+ so they have to be processed again.
Transitions:
* summary
@@ -904,22 +909,28 @@ def remove_package(self, picking_id, package_id):
return self._response_stock_picking_does_not_exist()
package = self.env["stock.quant.package"].browse(package_id).exists()
- if not package:
+ line = self.env["stock.move.line"].browse(line_id).exists()
+ if not package and not line:
return self._response_for_summary(
picking, message=message.record_not_found()
)
- move_lines = picking.move_line_ids.filtered(
- lambda l: self._filter_lines_checkout_done(l)
- and l.result_package_id == package
- )
- for move_line in move_lines:
- move_line.write(
- {
- "qty_done": 0,
- "result_package_id": move_line.package_id,
- "shopfloor_checkout_done": False,
- }
+
+ if package:
+ move_lines = picking.move_line_ids.filtered(
+ lambda l: self._filter_lines_checkout_done(l)
+ and l.result_package_id == package
)
+ for move_line in move_lines:
+ move_line.write(
+ {
+ "qty_done": 0,
+ "result_package_id": move_line.package_id,
+ "shopfloor_checkout_done": False,
+ }
+ )
+ if line:
+ line.write({"qty_done": 0, "shopfloor_checkout_done": False})
+
return self._response_for_summary(picking)
def done(self, picking_id, confirmation=False):
@@ -1105,10 +1116,21 @@ def set_packaging(self):
"packaging_id": {"coerce": to_int, "required": True, "type": "integer"},
}
- def remove_package(self):
+ def cancel_line(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
- "package_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ "excludes": "line_id",
+ },
+ "line_id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ "excludes": "package_id",
+ },
}
def done(self):
@@ -1267,7 +1289,7 @@ def list_packaging(self):
def set_packaging(self):
return self._response_schema(next_states={"change_packaging", "summary"})
- def remove_package(self):
+ def cancel_line(self):
return self._response_schema(next_states={"summary"})
def done(self):
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 87e4ace1c2..baa5b40e96 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -23,6 +23,6 @@
from . import test_checkout_list_package
from . import test_checkout_summary
from . import test_checkout_change_packaging
-from . import test_checkout_remove_package
+from . import test_checkout_cancel_line
from . import test_checkout_done
from . import test_delivery_base
diff --git a/shopfloor/tests/test_checkout_remove_package.py b/shopfloor/tests/test_checkout_cancel_line.py
similarity index 70%
rename from shopfloor/tests/test_checkout_remove_package.py
rename to shopfloor/tests/test_checkout_cancel_line.py
index 09c8e73976..76f5cda812 100644
--- a/shopfloor/tests/test_checkout_remove_package.py
+++ b/shopfloor/tests/test_checkout_cancel_line.py
@@ -21,7 +21,7 @@ def setUpClass(cls):
cls._fill_stock_for_moves(cls.raw_move)
picking.action_assign()
- def test_remove_package_ok(self):
+ def test_cancel_package_ok(self):
picking = self.picking
pack1_lines = self.pack1_moves.move_line_ids
@@ -48,7 +48,7 @@ def test_remove_package_ok(self):
# and now, we want to drop the new_package
response = self.service.dispatch(
- "remove_package",
+ "cancel_line",
params={"picking_id": picking.id, "package_id": new_package.id},
)
@@ -88,10 +88,47 @@ def test_remove_package_ok(self):
data={"picking": self._stock_picking_data(picking, done=True)},
)
- def test_remove_package_error_package_not_found(self):
+ def test_cancel_line_ok(self):
+ picking = self.picking
+
+ raw_line = self.raw_move.move_line_ids
+
+ raw_line.write({"qty_done": 10, "shopfloor_checkout_done": True})
+
+ # and now, we want to drop the new_package
+ response = self.service.dispatch(
+ "cancel_line", params={"picking_id": picking.id, "line_id": raw_line.id},
+ )
+
+ self.assertRecordValues(
+ raw_line, [{"qty_done": 0, "shopfloor_checkout_done": False}],
+ )
+
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={"picking": self._stock_picking_data(picking, done=True)},
+ )
+
+ def test_cancel_line_error_package_not_found(self):
+ # and now, we want to drop the new_package
+ response = self.service.dispatch(
+ "cancel_line", params={"picking_id": self.picking.id, "package_id": 0}
+ )
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={"picking": self._stock_picking_data(self.picking, done=True)},
+ message={
+ "message_type": "error",
+ "message": "The record you were working on does not exist anymore.",
+ },
+ )
+
+ def test_cancel_line_error_line_not_found(self):
# and now, we want to drop the new_package
response = self.service.dispatch(
- "remove_package", params={"picking_id": self.picking.id, "package_id": 0}
+ "cancel_line", params={"picking_id": self.picking.id, "line_id": 0}
)
self.assert_response(
response,
From 91c1dc0f4450e0b244a3802c528828b74f473bab Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 29 Apr 2020 16:24:18 +0200
Subject: [PATCH 180/940] backend: checkout fix lookup of pickings via package
---
shopfloor/services/checkout.py | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index e8c59d4c08..9746e2bf7d 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -85,8 +85,17 @@ def scan_document(self, barcode):
if not picking:
package = search.package_from_scan(barcode)
if package:
- lines = package.planned_move_line_ids
- pickings = lines.mapped("picking_id")
+ pickings = (
+ self.env["stock.move.line"]
+ .search(
+ [
+ ("state", "not in", ("cancel", "done")),
+ ("package_id", "=", package.id),
+ ("picking_id.picking_type_id", "=", self.picking_type.id),
+ ]
+ )
+ .mapped("picking_id")
+ )
if len(pickings) == 1:
picking = pickings
return self._select_picking(picking, "select_document")
From 6dbd56cc7be8a26d3abdef9a958c06163ded6258 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 29 Apr 2020 16:25:42 +0200
Subject: [PATCH 181/940] backend: checkout summary add 'all_processed' flag
---
shopfloor/services/checkout.py | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 9746e2bf7d..236356bd5a 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -140,7 +140,10 @@ def _response_for_select_line(self, picking, message=None):
def _response_for_summary(self, picking, need_confirm=False, message=None):
return self._response(
next_state="summary" if not need_confirm else "confirm_done",
- data={"picking": self._data_for_stock_picking(picking, done=True)},
+ data={
+ "picking": self._data_for_stock_picking(picking, done=True),
+ "all_processed": not bool(self._lines_to_pack(picking)),
+ },
message=message,
)
@@ -965,8 +968,8 @@ def done(self, picking_id, confirmation=False):
message={
"message_type": "warning",
"message": _(
- "Not all lines have been processed, do you"
- " want to confirm partial operation?"
+ "Not all lines have been processed with full quantity. "
+ "Do you confirm partial operation?"
),
},
)
@@ -1171,7 +1174,7 @@ def _states(self):
"select_package": self._schema_selected_lines,
"change_quantity": self._schema_selected_lines,
"select_dest_package": self._schema_select_package,
- "summary": self._schema_stock_picking_details,
+ "summary": self._schema_summary,
"change_packaging": self._schema_select_packaging,
"confirm_done": self._schema_stock_picking_details,
}
@@ -1184,11 +1187,17 @@ def _schema_stock_picking_details(self):
"move_lines": {
"type": "list",
"schema": {"type": "dict", "schema": self.schemas().move_line()},
- }
+ },
}
)
return {"picking": {"type": "dict", "schema": schema}}
+ @property
+ def _schema_summary(self):
+ return dict(
+ self._schema_stock_picking_details, all_processed={"type": "boolean"}
+ )
+
@property
def _schema_selection_list(self):
return {
From bd40d0179bb3fb7d570a874f2f319cdbf9719fa3 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 29 Apr 2020 16:39:03 +0200
Subject: [PATCH 182/940] backend: rename location dest return key
We should use data component to output location data
in all processes. So far it has been used only in checkout.
This change makes the schema similar while we are not using it yet
---
shopfloor/services/cluster_picking.py | 12 ++++----
shopfloor/services/single_pack_putaway.py | 4 +--
shopfloor/services/single_pack_transfer.py | 4 +--
shopfloor/tests/test_cluster_picking_base.py | 2 +-
shopfloor/tests/test_cluster_picking_scan.py | 2 +-
.../tests/test_cluster_picking_select.py | 2 +-
.../tests/test_cluster_picking_unload.py | 28 +++++++++----------
shopfloor/tests/test_single_pack_putaway.py | 6 ++--
shopfloor/tests/test_single_pack_transfer.py | 6 ++--
9 files changed, 33 insertions(+), 33 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 41da540a84..0ec6fd0ee4 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -297,7 +297,7 @@ def _data_move_line(self, line):
if lot
else None,
"location_src": {"id": line.location_id.id, "name": line.location_id.name},
- "location_dst": {
+ "location_dest": {
"id": line.location_dest_id.id,
"name": line.location_dest_id.name,
},
@@ -637,7 +637,7 @@ def _data_for_unload_all(self, batch):
return {
"id": batch.id,
"name": batch.name,
- "location_dst": {
+ "location_dest": {
"id": first_line.location_dest_id.id,
"name": first_line.location_dest_id.name,
},
@@ -652,7 +652,7 @@ def _data_for_unload_single(self, batch, package):
"id": batch.id,
"name": batch.name,
"package": {"id": package.id, "name": package.name},
- "location_dst": {
+ "location_dest": {
"id": line.location_dest_id.id,
"name": line.location_dest_id.name,
},
@@ -1435,7 +1435,7 @@ def _schema_for_single_line_details(self):
"name": {"type": "string", "nullable": False, "required": True},
},
},
- "location_dst": {
+ "location_dest": {
"type": "dict",
"schema": {
"id": {"required": True, "type": "integer"},
@@ -1468,7 +1468,7 @@ def _schema_for_unload_all(self):
# stock.batch.picking
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
- "location_dst": {
+ "location_dest": {
"type": "dict",
"schema": {
"id": {"required": True, "type": "integer"},
@@ -1490,7 +1490,7 @@ def _schema_for_unload_single(self):
"name": {"type": "string", "nullable": False, "required": True},
},
},
- "location_dst": {
+ "location_dest": {
"type": "dict",
"schema": {
"id": {"required": True, "type": "integer"},
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 9c2857d348..73f4e5fdab 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -59,7 +59,7 @@ def _data_after_package_scanned(self, move_line, pack):
"id": move_line.package_level_id.id,
"name": pack.name,
"location_src": {"id": pack.location_id.id, "name": pack.location_id.name},
- "location_dst": {
+ "location_dest": {
"id": move_line.location_dest_id.id,
"name": move_line.location_dest_id.name,
},
@@ -328,7 +328,7 @@ def _schema_for_location(self):
"name": {"type": "string", "nullable": False, "required": True},
},
},
- "location_dst": {
+ "location_dest": {
"type": "dict",
"schema": {
"id": {"required": True, "type": "integer"},
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index edef7da3b1..d2db50456f 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -57,7 +57,7 @@ def _data_after_package_scanned(self, move_line, pack):
"id": move_line.package_level_id.id,
"name": pack.name,
"location_src": {"id": pack.location_id.id, "name": pack.location_id.name},
- "location_dst": {
+ "location_dest": {
"id": move_line.location_dest_id.id,
"name": move_line.location_dest_id.name,
},
@@ -299,7 +299,7 @@ def _schema_for_location(self):
"name": {"type": "string", "nullable": False, "required": True},
},
},
- "location_dst": {
+ "location_dest": {
"type": "dict",
"schema": {
"id": {"required": True, "type": "integer"},
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index 62c1aa6550..7463a7e0b2 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -46,7 +46,7 @@ def _line_data(self, move_line, qty=None):
"id": move_line.id,
"quantity": qty or move_line.product_uom_qty,
"postponed": move_line.shopfloor_postponed,
- "location_dst": {
+ "location_dest": {
"id": move_line.location_dest_id.id,
"name": move_line.location_dest_id.name,
},
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 5192134661..181b9e0e15 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -350,7 +350,7 @@ def test_scan_destination_pack_ok_last_line(self):
data={
"id": self.batch.id,
"name": self.batch.name,
- "location_dst": {
+ "location_dest": {
"id": self.packing_location.id,
"name": self.packing_location.name,
},
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index 5bc64cd816..dfc89c6c2a 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -367,7 +367,7 @@ def test_confirm_start_ok(self):
"id": first_move_line.id,
"quantity": 1.0,
"postponed": False,
- "location_dst": {
+ "location_dest": {
"id": first_move_line.location_dest_id.id,
"name": first_move_line.location_dest_id.name,
},
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 5668308fe2..34de5253ad 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -63,7 +63,7 @@ def test_prepare_unload_all_same_dest(self):
data={
"id": self.batch.id,
"name": self.batch.name,
- "location_dst": {
+ "location_dest": {
"id": self.packing_location.id,
"name": self.packing_location.name,
},
@@ -89,7 +89,7 @@ def test_prepare_unload_different_dest(self):
"id": self.batch.id,
"name": self.batch.name,
"package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
+ "location_dest": {
"id": first_line.location_dest_id.id,
"name": first_line.location_dest_id.name,
},
@@ -249,7 +249,7 @@ def test_set_destination_all_but_different_dest(self):
"id": self.batch.id,
"name": self.batch.name,
"package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
+ "location_dest": {
"id": move_lines[0].location_dest_id.id,
"name": move_lines[0].location_dest_id.name,
},
@@ -272,7 +272,7 @@ def test_set_destination_all_error_location_not_found(self):
data={
"id": self.batch.id,
"name": self.batch.name,
- "location_dst": {
+ "location_dest": {
"id": move_lines[0].location_dest_id.id,
"name": move_lines[0].location_dest_id.name,
},
@@ -306,7 +306,7 @@ def test_set_destination_all_error_location_invalid(self):
data={
"id": self.batch.id,
"name": self.batch.name,
- "location_dst": {
+ "location_dest": {
"id": move_lines[0].location_dest_id.id,
"name": move_lines[0].location_dest_id.name,
},
@@ -333,7 +333,7 @@ def test_set_destination_all_need_confirmation(self):
data={
"id": self.batch.id,
"name": self.batch.name,
- "location_dst": {
+ "location_dest": {
"id": move_lines[0].location_dest_id.id,
"name": move_lines[0].location_dest_id.name,
},
@@ -406,7 +406,7 @@ def test_unload_split_ok(self):
"id": self.batch.id,
"name": self.batch.name,
"package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
+ "location_dest": {
"id": move_lines[0].location_dest_id.id,
"name": move_lines[0].location_dest_id.name,
},
@@ -450,7 +450,7 @@ def test_unload_scan_pack_ok(self):
"id": self.batch.id,
"name": self.batch.name,
"package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
+ "location_dest": {
"id": self.move_lines[0].location_dest_id.id,
"name": self.move_lines[0].location_dest_id.name,
},
@@ -474,7 +474,7 @@ def test_unload_scan_pack_wrong_barcode(self):
"id": self.batch.id,
"name": self.batch.name,
"package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
+ "location_dest": {
"id": self.move_lines[0].location_dest_id.id,
"name": self.move_lines[0].location_dest_id.name,
},
@@ -567,7 +567,7 @@ def test_unload_scan_destination_ok(self):
"name": self.batch.name,
# the line of bin1 is unloaded, next one will be bin2
"package": {"id": self.bin2.id, "name": self.bin2.name},
- "location_dst": {
+ "location_dest": {
"id": self.bin2_lines[0].location_dest_id.id,
"name": self.bin2_lines[0].location_dest_id.name,
},
@@ -637,7 +637,7 @@ def test_unload_scan_destination_one_line_of_picking_only(self):
"name": self.batch.name,
# the line of bin2 is unloaded, next one will be bin3
"package": {"id": bin3.id, "name": bin3.name},
- "location_dst": {
+ "location_dest": {
"id": bin3_line.location_dest_id.id,
"name": bin3_line.location_dest_id.name,
},
@@ -708,7 +708,7 @@ def test_unload_scan_destination_error_location_not_found(self):
"id": self.batch.id,
"name": self.batch.name,
"package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
+ "location_dest": {
"id": self.bin1_lines[0].location_dest_id.id,
"name": self.bin1_lines[0].location_dest_id.name,
},
@@ -740,7 +740,7 @@ def test_unload_scan_destination_error_location_invalid(self):
"id": self.batch.id,
"name": self.batch.name,
"package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
+ "location_dest": {
"id": self.bin1_lines[0].location_dest_id.id,
"name": self.bin1_lines[0].location_dest_id.name,
},
@@ -765,7 +765,7 @@ def test_unload_scan_destination_need_confirmation(self):
"id": self.batch.id,
"name": self.batch.name,
"package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dst": {
+ "location_dest": {
"id": self.bin1_lines[0].location_dest_id.id,
"name": self.bin1_lines[0].location_dest_id.name,
},
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index cada111056..e943dfe953 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -87,7 +87,7 @@ def test_start(self):
"id": self.dispatch_location.id,
"name": self.dispatch_location.name,
},
- "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name},
+ "location_dest": {"id": self.shelf1.id, "name": self.shelf1.name},
"name": package_level.package_id.name,
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
"product": {"id": move.product_id.id, "name": move.product_id.name},
@@ -222,7 +222,7 @@ def test_start_move_already_exist(self):
"id": self.dispatch_location.id,
"name": self.dispatch_location.name,
},
- "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name},
+ "location_dest": {"id": self.shelf1.id, "name": self.shelf1.name},
"name": package_level.package_id.name,
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
"product": {"id": self.product_a.id, "name": self.product_a.name},
@@ -423,7 +423,7 @@ def test_validate_location_to_confirm(self):
"id": self.dispatch_location.id,
"name": self.dispatch_location.name,
},
- "location_dst": {"id": self.shelf1.id, "name": self.shelf1.name},
+ "location_dest": {"id": self.shelf1.id, "name": self.shelf1.name},
"name": package_level.package_id.name,
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
"product": {"id": move.product_id.id, "name": move.product_id.name},
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index c5c49c4261..5a91659502 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -97,7 +97,7 @@ def test_start(self):
"id": self.ANY,
"name": package_level.package_id.name,
"location_src": {"id": self.shelf1.id, "name": self.shelf1.name},
- "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name},
+ "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name},
"picking": {"id": self.picking.id, "name": self.picking.name},
"product": {"id": self.product_a.id, "name": self.product_a.name},
},
@@ -320,7 +320,7 @@ def test_start_already_started(self):
"id": self.ANY,
"name": package_level.package_id.name,
"location_src": {"id": self.shelf1.id, "name": self.shelf1.name},
- "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name},
+ "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name},
"picking": {"id": self.picking.id, "name": self.picking.name},
"product": {"id": self.product_a.id, "name": self.product_a.name},
},
@@ -544,7 +544,7 @@ def test_validate_location_to_confirm(self):
"id": self.ANY,
"name": package_level.package_id.name,
"location_src": {"id": self.shelf1.id, "name": self.shelf1.name},
- "location_dst": {"id": self.shelf2.id, "name": self.shelf2.name},
+ "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name},
"picking": {"id": self.picking.id, "name": self.picking.name},
"product": {"id": self.product_a.id, "name": self.product_a.name},
},
From 9d749264d8200a8417451aaed313dfb8a5f281fd Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 30 Apr 2020 15:46:18 +0200
Subject: [PATCH 183/940] backend: load menu by profile
---
shopfloor/controllers/main.py | 62 +++++++++++++++++++++++------------
shopfloor/services/app.py | 28 +++++++++++-----
shopfloor/services/menu.py | 22 ++++++++-----
shopfloor/tests/test_app.py | 56 ++++++++++++++++++++++++++-----
4 files changed, 122 insertions(+), 46 deletions(-)
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index 87ce6ef967..c5330310e9 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -4,12 +4,31 @@
from odoo.addons.base_rest.controllers import main
+MENU_ID_HEADER = "HTTP_SERVICE_CTX_MENU_ID"
+# (name, model, dest_key)
+MENU_HEADER_RULE = (MENU_ID_HEADER, "shopfloor.menu", "menu")
+
+PROFILE_ID_HEADER = "HTTP_SERVICE_CTX_PROFILE_ID"
+PROFILE_HEADER_RULE = (PROFILE_ID_HEADER, "shopfloor.profile", "profile")
+
class ShopfloorController(main.RestController):
_root_path = "/shopfloor/"
_collection_name = "shopfloor.service"
_default_auth = "api_key"
- _non_process_services = ("app", "menu", "profile")
+ _service_headers_rules = {
+ # no special header required for config
+ "app/user_config": (),
+ # profile header is required to get menu items
+ # fmt: off
+ # NOTE: turn off formatting here is mandatory
+ # otherwise black removes the space and flake8 w/ complain the comma
+ # before parenthesis which is required to make this a tuple!
+ "app/menu": (PROFILE_HEADER_RULE, ),
+ # profile + menu is required to call processes
+ "process": (PROFILE_HEADER_RULE, MENU_HEADER_RULE, ),
+ # fmt: on
+ }
def _get_component_context(self):
"""
@@ -19,30 +38,31 @@ def _get_component_context(self):
Components
"""
res = super(ShopfloorController, self)._get_component_context()
- headers = request.httprequest.environ
-
res["menu"] = None
res["profile"] = None
- if self._is_process_enpoint(request.httprequest.path):
- res.update(self._get_process_context(headers, request.env))
+ res.update(self._get_process_context(request))
return res
- def _is_process_enpoint(self, request_path):
+ # TODO: add tests
+ def _get_process_context(self, request):
+ ctx = {}
+ env = request.env
+ headers = request.httprequest.environ
# '/shopfloor/app/user_config' -> app/config
- service_path = request_path.split(self._root_path)[-1]
- return not service_path.startswith(self._non_process_services)
+ service_path = request.httprequest.path.split(self._root_path)[-1]
- def _get_process_context(self, headers, env):
- ctx = {}
- try:
- menu_id = int(headers.get("HTTP_SERVICE_CTX_MENU_ID"))
- except (TypeError, ValueError):
- raise BadRequest("HTTP_SERVICE_CTX_MENU_ID must be set with an integer")
- ctx["menu"] = env["shopfloor.menu"].browse(menu_id)
-
- try:
- profile_id = int(headers.get("HTTP_SERVICE_CTX_PROFILE_ID"))
- except (TypeError, ValueError):
- raise BadRequest("HTTP_SERVICE_CTX_PROFILE_ID must be set with an integer")
- ctx["profile"] = env["shopfloor.profile"].browse(profile_id)
+ # default to process rule
+ default = self._service_headers_rules["process"]
+ headers_map = self._service_headers_rules.get(service_path, default)
+ for header_name, model, dest_key in headers_map:
+ try:
+ rec_id = int(headers.get(header_name))
+ except (TypeError, ValueError):
+ raise BadRequest("{} must be set with an integer".format(header_name))
+ rec = env[model].browse(rec_id).exists()
+ if not rec:
+ raise BadRequest(
+ "Record {} with ID = {} not found".format(model, rec_id)
+ )
+ ctx[dest_key] = rec
return ctx
diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py
index 997c33eda4..0a87f0b6e3 100644
--- a/shopfloor/services/app.py
+++ b/shopfloor/services/app.py
@@ -10,11 +10,14 @@ class ShopfloorApp(Component):
_description = __doc__
def user_config(self):
- menu_comp = self.component("menu")
profiles_comp = self.component("profile")
- menus = menu_comp._to_json(menu_comp._search())
profiles = profiles_comp._to_json(profiles_comp._search())
- return self._response(data={"menus": menus, "profiles": profiles})
+ return self._response(data={"profiles": profiles})
+
+ def menu(self):
+ menu_comp = self.component("menu")
+ menus = menu_comp._to_json(menu_comp._search())
+ return self._response(data={"menus": menus})
class ShopfloorAppValidator(Component):
@@ -27,6 +30,9 @@ class ShopfloorAppValidator(Component):
def user_config(self):
return {}
+ def menu(self):
+ return {}
+
class ShopfloorAppValidatorResponse(Component):
"""Validators for the Application endpoints responses"""
@@ -36,24 +42,30 @@ class ShopfloorAppValidatorResponse(Component):
_usage = "app.validator.response"
def user_config(self):
- menu_return_validator = self.component("menu.validator.response")
profile_return_validator = self.component("profile.validator.response")
return self._response_schema(
{
- "menus": {
+ "profiles": {
"type": "list",
"required": True,
"schema": {
"type": "dict",
- "schema": menu_return_validator._record_schema,
+ "schema": profile_return_validator._record_schema,
},
},
- "profiles": {
+ }
+ )
+
+ def menu(self):
+ menu_return_validator = self.component("menu.validator.response")
+ return self._response_schema(
+ {
+ "menus": {
"type": "list",
"required": True,
"schema": {
"type": "dict",
- "schema": profile_return_validator._record_schema,
+ "schema": menu_return_validator._record_schema,
},
},
}
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 140c3bfde3..d516f9e029 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -22,16 +22,20 @@ class ShopfloorMenu(Component):
def _get_base_search_domain(self):
base_domain = super()._get_base_search_domain()
user = self.env.user
- return expression.AND(
- [
- base_domain,
- [
- "|",
- ("operation_group_ids", "=", False),
- ("operation_group_ids.user_ids", "=", user.id),
- ],
+ profile = getattr(self.work, "profile", None)
+ op_group_domain = [
+ "|",
+ ("operation_group_ids", "=", False),
+ ("operation_group_ids.user_ids", "=", user.id),
+ ]
+ if profile:
+ # TODO: this probably should be the default only one way
+ # What to do w/ profiles linked to specific user?
+ # This data model is a bit messy :/
+ op_group_domain = [
+ ("operation_group_ids", "in", profile.operation_group_ids.ids),
]
- )
+ return expression.AND([base_domain, op_group_domain])
def _search(self, name_fragment=None):
domain = self._get_base_search_domain()
diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py
index f64bb0f91e..4c6039ce1b 100644
--- a/shopfloor/tests/test_app.py
+++ b/shopfloor/tests/test_app.py
@@ -11,8 +11,29 @@ def test_user_config(self):
"""Request /app/user_config"""
# Simulate the client asking the configuration
response = self.service.dispatch("user_config")
- menus = self.env["shopfloor.menu"].search([])
profiles = self.env["shopfloor.profile"].search([])
+ self.assert_response(
+ response,
+ data={
+ "profiles": [
+ {
+ "id": profile.id,
+ "name": profile.name,
+ "warehouse": {
+ "id": profile.warehouse_id.id,
+ "name": profile.warehouse_id.name,
+ },
+ }
+ for profile in profiles
+ ],
+ },
+ )
+
+ def test_menu_no_profile(self):
+ """Request /app/menu"""
+ # Simulate the client asking the menu
+ response = self.service.dispatch("menu")
+ menus = self.env["shopfloor.menu"].search([])
self.assert_response(
response,
data={
@@ -27,16 +48,35 @@ def test_user_config(self):
}
for menu in menus
],
- "profiles": [
+ },
+ )
+
+ def test_menu_by_profile(self):
+ """Request /app/menu w/ a specific profile"""
+ # Simulate the client asking the menu
+ op_type = self.env.ref("shopfloor.shopfloor_operation_group_highbay_demo")
+ menus = self.env["shopfloor.menu"].search([])
+ menu = menus[0]
+ menu.operation_group_ids = op_type
+ profile = self.env.ref("shopfloor.shopfloor_profile_hb_truck_demo")
+ profile.operation_group_ids = op_type
+
+ with self.work_on_services(profile=profile) as work:
+ service = work.component(usage="app")
+
+ response = service.dispatch("menu")
+ self.assert_response(
+ response,
+ data={
+ "menus": [
{
- "id": profile.id,
- "name": profile.name,
- "warehouse": {
- "id": profile.warehouse_id.id,
- "name": profile.warehouse_id.name,
+ "id": menu.id,
+ "name": menu.name,
+ "process": {
+ "id": menu.process_id.id,
+ "code": menu.process_id.code,
},
}
- for profile in profiles
],
},
)
From e4681b481866ac55482ba490a093787865a9bbd8 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 4 May 2020 13:13:06 +0200
Subject: [PATCH 184/940] backend: fix checkout optional required args
---
shopfloor/services/checkout.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 236356bd5a..1604902196 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -1133,13 +1133,14 @@ def cancel_line(self):
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
"package_id": {
"coerce": to_int,
- "required": True,
+ "required": False,
"type": "integer",
+ # excludes does not set the other as not required??? :/
"excludes": "line_id",
},
"line_id": {
"coerce": to_int,
- "required": True,
+ "required": False,
"type": "integer",
"excludes": "package_id",
},
From b855833a277dac909915174d1689f0b89fc42d27 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 4 May 2020 16:01:22 +0200
Subject: [PATCH 185/940] backend: include more details in menu item data
---
shopfloor/services/menu.py | 60 +++++++++++++++++++++++++++++--------
shopfloor/tests/test_app.py | 13 ++++++++
2 files changed, 60 insertions(+), 13 deletions(-)
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index d516f9e029..839d5634f3 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -52,10 +52,21 @@ def search(self, name_fragment=None):
)
def _convert_one_record(self, record):
+ # TODO: use `jsonify`
return {
"id": record.id,
"name": record.name,
- "process": {"id": record.process_id.id, "code": record.process_code},
+ "process": {
+ "id": record.process_id.id,
+ "code": record.process_code,
+ "picking_type": {
+ "id": record.process_id.picking_type_id.id,
+ "name": record.process_id.picking_type_id.name,
+ },
+ },
+ "op_groups": [
+ {"id": g.id, "name": g.name} for g in record.operation_group_ids
+ ],
}
@@ -79,29 +90,52 @@ class ShopfloorMenuValidatorResponse(Component):
_name = "shopfloor.menu.validator.response"
_usage = "menu.validator.response"
+ def return_search(self):
+ return self._response_schema(
+ {
+ "size": {"coerce": to_int, "required": True, "type": "integer"},
+ "records": {
+ "type": "list",
+ "required": True,
+ "schema": {"type": "dict", "schema": self._record_schema},
+ },
+ }
+ )
+
@property
def _record_schema(self):
return {
"id": {"coerce": to_int, "required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
+ "op_groups": {
+ "type": "list",
+ "required": True,
+ "schema": {"type": "dict", "schema": self._op_group_schema},
+ },
"process": {
"type": "dict",
"required": True,
"schema": {
- "code": {"type": "string", "nullable": False, "required": True},
"id": {"coerce": to_int, "required": True, "type": "integer"},
+ "code": {"type": "string", "nullable": False, "required": True},
+ "picking_type": {
+ "type": "dict",
+ "schema": self._picking_type_schema,
+ },
},
},
}
- def return_search(self):
- return self._response_schema(
- {
- "size": {"coerce": to_int, "required": True, "type": "integer"},
- "records": {
- "type": "list",
- "required": True,
- "schema": {"type": "dict", "schema": self._record_schema},
- },
- }
- )
+ @property
+ def _op_group_schema(self):
+ return {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ }
+
+ @property
+ def _picking_type_schema(self):
+ return {
+ "id": {"coerce": to_int, "required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ }
diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py
index 4c6039ce1b..c3cab5356a 100644
--- a/shopfloor/tests/test_app.py
+++ b/shopfloor/tests/test_app.py
@@ -41,9 +41,17 @@ def test_menu_no_profile(self):
{
"id": menu.id,
"name": menu.name,
+ "op_groups": [
+ {"id": x.id, "name": x.name}
+ for x in menu.operation_group_ids
+ ],
"process": {
"id": menu.process_id.id,
"code": menu.process_id.code,
+ "picking_type": {
+ "id": menu.process_id.picking_type_id.id,
+ "name": menu.process_id.picking_type_id.name,
+ },
},
}
for menu in menus
@@ -72,9 +80,14 @@ def test_menu_by_profile(self):
{
"id": menu.id,
"name": menu.name,
+ "op_groups": [{"id": op_type.id, "name": op_type.name}],
"process": {
"id": menu.process_id.id,
"code": menu.process_id.code,
+ "picking_type": {
+ "id": menu.process_id.picking_type_id.id,
+ "name": menu.process_id.picking_type_id.name,
+ },
},
}
],
From b2315c7dcf3cc7b2b0f409856b2301d664b8d292 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 4 May 2020 16:14:53 +0200
Subject: [PATCH 186/940] backend: add handy debug fields to move line tree
---
shopfloor/__manifest__.py | 1 +
shopfloor/views/stock_move_line.xml | 34 +++++++++++++++++++++++++++++
2 files changed, 35 insertions(+)
create mode 100644 shopfloor/views/stock_move_line.xml
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index edba0364fa..2342b041d1 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -30,6 +30,7 @@
"views/shopfloor_process.xml",
"views/stock_picking_type.xml",
"views/stock_location.xml",
+ "views/stock_move_line.xml",
"views/shopfloor_profile_views.xml",
"views/menus.xml",
],
diff --git a/shopfloor/views/stock_move_line.xml b/shopfloor/views/stock_move_line.xml
new file mode 100644
index 0000000000..3945954cdb
--- /dev/null
+++ b/shopfloor/views/stock_move_line.xml
@@ -0,0 +1,34 @@
+
+
+
+ shopfloor stock_move_line_detailed_operation_tree
+ stock.move.line
+
+
+
+
+
+
+
+
+
+
+
From 261ba8692980d766af7c7398d259311290565d34 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 4 May 2020 09:55:12 +0200
Subject: [PATCH 187/940] backend: add TODO
---
shopfloor/models/stock_quant_package.py | 7 +++++++
shopfloor/services/service.py | 3 +++
2 files changed, 10 insertions(+)
diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py
index d41c531f2f..c835935fb3 100644
--- a/shopfloor/models/stock_quant_package.py
+++ b/shopfloor/models/stock_quant_package.py
@@ -10,3 +10,10 @@ class StockQuantPackage(models.Model):
readonly=True,
help="Technical field. Move lines for which destination is this package.",
)
+
+ # TODO: we should refactor this like
+
+ # source_planned_move_line_ids
+ # destination_planned_move_line_ids
+
+ # filter out done/cancel lines
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 6c0861f9f6..46e2d63bde 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -28,6 +28,9 @@ def picking_type(self):
"""
Get the current picking type based on the menu and the warehouse of the profile.
"""
+ # TODO: this validation should be done in the service
+ # and should return a proper error for the client
+ # or the client should handle exceptions properly.
picking_type = self.work.menu.process_id.picking_type_id
if picking_type.warehouse_id != self.work.profile.warehouse_id:
raise exceptions.UserError(
From caef06e92badf1383a2c00241815635bd9cf7a54 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 5 May 2020 09:40:16 +0200
Subject: [PATCH 188/940] backend: unify batch picking weight calc
---
shopfloor/models/stock_picking_batch.py | 12 ++++++++++++
shopfloor/services/cluster_picking.py | 4 +---
shopfloor/services/picking_batch.py | 2 ++
3 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py
index 4101de4da5..c3f3173b03 100644
--- a/shopfloor/models/stock_picking_batch.py
+++ b/shopfloor/models/stock_picking_batch.py
@@ -10,3 +10,15 @@ class StockPickingBatch(models.Model):
help="Technical field. Indicates if a batch is destination is"
" asked once for all lines or for every line.",
)
+
+ def total_weight(self):
+ return self.calc_weight(self.picking_ids)
+
+ def picking_weight(self, picking):
+ return self.calc_weight(picking)
+
+ def calc_weight(self, pickings):
+ weight = 0.0
+ for move_line in pickings.mapped("move_line_ids"):
+ weight += move_line.product_qty * move_line.product_id.weight
+ return weight
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 0ec6fd0ee4..5de76b4244 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -143,9 +143,7 @@ def _response_for_no_batch_found(self):
def _response_for_confirm_start(self, batch):
pickings = []
for picking in batch.picking_ids:
- p_weight = 0.0
- for move_line in picking.mapped("move_line_ids"):
- p_weight += move_line.product_qty * move_line.product_id.weight
+ p_weight = batch.picking_weight(picking)
p_values = {
"id": picking.id,
"name": picking.name,
diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py
index 2838373dc3..de5086ddf0 100644
--- a/shopfloor/services/picking_batch.py
+++ b/shopfloor/services/picking_batch.py
@@ -77,6 +77,7 @@ def _convert_one_record(self, record):
"name": record.name,
"picking_count": len(assigned_pickings),
"move_line_count": len(assigned_pickings.mapped("move_line_ids")),
+ "weight": record.total_weight(),
}
@@ -119,4 +120,5 @@ def _record_schema(self):
"name": {"type": "string", "nullable": False, "required": True},
"picking_count": {"required": True, "type": "integer"},
"move_line_count": {"required": True, "type": "integer"},
+ "weight": {"required": True, "nullable": True, "type": "float"},
}
From 16589444a5e61cf67f9d0a599dfeb83c0b2ea07d Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 5 May 2020 17:51:52 +0200
Subject: [PATCH 189/940] backend: fix tests
---
shopfloor/actions/search.py | 13 ++++++++++++
shopfloor/services/checkout.py | 12 +----------
shopfloor/tests/test_checkout_cancel_line.py | 20 +++++++++++++++----
.../tests/test_checkout_change_packaging.py | 20 +++++++++++++++----
shopfloor/tests/test_checkout_done.py | 4 ++--
shopfloor/tests/test_checkout_scan.py | 12 +++++------
shopfloor/tests/test_checkout_scan_line.py | 5 ++++-
.../test_checkout_scan_package_action.py | 5 ++++-
shopfloor/tests/test_checkout_select_line.py | 5 ++++-
shopfloor/tests/test_checkout_summary.py | 5 ++++-
.../tests/test_cluster_picking_select.py | 2 ++
shopfloor/tests/test_menu.py | 5 +++++
shopfloor/tests/test_picking_batch.py | 3 +++
13 files changed, 79 insertions(+), 32 deletions(-)
diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py
index a547abbbc6..2bba15131d 100644
--- a/shopfloor/actions/search.py
+++ b/shopfloor/actions/search.py
@@ -37,3 +37,16 @@ def generic_packaging_from_scan(self, barcode):
return self.env["product.packaging"].search(
[("barcode", "=", barcode), ("product_id", "=", False)]
)
+
+ def stock_picking_from_package(self, package, picking_type):
+ return (
+ self.env["stock.move.line"]
+ .search(
+ [
+ ("state", "not in", ("cancel", "done")),
+ ("package_id", "=", package.id),
+ ("picking_id.picking_type_id", "=", picking_type.id),
+ ]
+ )
+ .mapped("picking_id")
+ )
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 1604902196..3d04bc72b8 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -85,17 +85,7 @@ def scan_document(self, barcode):
if not picking:
package = search.package_from_scan(barcode)
if package:
- pickings = (
- self.env["stock.move.line"]
- .search(
- [
- ("state", "not in", ("cancel", "done")),
- ("package_id", "=", package.id),
- ("picking_id.picking_type_id", "=", self.picking_type.id),
- ]
- )
- .mapped("picking_id")
- )
+ pickings = search.stock_picking_from_package(package, self.picking_type)
if len(pickings) == 1:
picking = pickings
return self._select_picking(picking, "select_document")
diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py
index 76f5cda812..cbe9aee027 100644
--- a/shopfloor/tests/test_checkout_cancel_line.py
+++ b/shopfloor/tests/test_checkout_cancel_line.py
@@ -85,7 +85,10 @@ def test_cancel_package_ok(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(self.picking, done=True),
+ "all_processed": False,
+ },
)
def test_cancel_line_ok(self):
@@ -107,7 +110,10 @@ def test_cancel_line_ok(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(self.picking, done=True),
+ "all_processed": False,
+ },
)
def test_cancel_line_error_package_not_found(self):
@@ -118,7 +124,10 @@ def test_cancel_line_error_package_not_found(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(self.picking, done=True),
+ "all_processed": False,
+ },
message={
"message_type": "error",
"message": "The record you were working on does not exist anymore.",
@@ -133,7 +142,10 @@ def test_cancel_line_error_line_not_found(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(self.picking, done=True),
+ "all_processed": False,
+ },
message={
"message_type": "error",
"message": "The record you were working on does not exist anymore.",
diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py
index 3d06fc48ad..ea1c4c6e74 100644
--- a/shopfloor/tests/test_checkout_change_packaging.py
+++ b/shopfloor/tests/test_checkout_change_packaging.py
@@ -69,7 +69,10 @@ def test_list_packaging_error_package_not_found(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(self.picking, done=True),
+ "all_processed": False,
+ },
message={
"message_type": "error",
"message": "The record you were working on does not exist anymore.",
@@ -91,7 +94,10 @@ def test_set_packaging_ok(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(self.picking, done=True),
+ "all_processed": False,
+ },
message={
"message_type": "success",
"message": "Packaging changed on package {}".format(self.package.name),
@@ -110,7 +116,10 @@ def test_set_packaging_error_package_not_found(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(self.picking, done=True),
+ "all_processed": False,
+ },
message={
"message_type": "error",
"message": "The record you were working on does not exist anymore.",
@@ -129,7 +138,10 @@ def test_set_packaging_error_packaging_not_found(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(self.picking, done=True),
+ "all_processed": False,
+ },
message={
"message_type": "error",
"message": "The record you were working on does not exist anymore.",
diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py
index f47db4fd6c..2ddb794c7c 100644
--- a/shopfloor/tests/test_checkout_done.py
+++ b/shopfloor/tests/test_checkout_done.py
@@ -48,8 +48,8 @@ def test_done_partial(self):
data={"picking": self._stock_picking_data(self.picking, done=True)},
message={
"message_type": "warning",
- "message": "Not all lines have been processed, do"
- " you want to confirm partial operation?",
+ "message": "Not all lines have been processed with full quantity. "
+ "Do you confirm partial operation?",
},
)
diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py
index 43ba2cc6dd..4972a278cd 100644
--- a/shopfloor/tests/test_checkout_scan.py
+++ b/shopfloor/tests/test_checkout_scan.py
@@ -80,7 +80,7 @@ def test_scan_document_error_location_not_child_of_type(self):
message={"message_type": "error", "message": "Location not allowed here."},
)
- def _test_scan_document_error_different_picking_type(self, barcode_func):
+ def _test_scan_document_error_different_picking_type(self, barcode_func, msg):
picking = self._create_picking(picking_type=self.wh.pick_type_id)
self._fill_stock_for_moves(picking.move_lines, in_package=True)
picking.action_assign()
@@ -89,20 +89,18 @@ def _test_scan_document_error_different_picking_type(self, barcode_func):
self.assert_response(
response,
next_state="select_document",
- message={
- "message_type": "error",
- "message": "You cannot move this using this menu.",
- },
+ message={"message_type": "error", "message": msg},
)
def test_scan_document_error_different_picking_type_picking(self):
self._test_scan_document_error_different_picking_type(
- lambda picking: picking.name
+ lambda picking: picking.name, msg="You cannot move this using this menu."
)
def test_scan_document_error_different_picking_type_package(self):
self._test_scan_document_error_different_picking_type(
- lambda picking: picking.move_line_ids.package_id.name
+ lambda picking: picking.move_line_ids.package_id.name,
+ msg="Barcode not found",
)
def test_scan_document_error_location_several_pickings(self):
diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py
index 8163bcdfcd..720bfb9973 100644
--- a/shopfloor/tests/test_checkout_scan_line.py
+++ b/shopfloor/tests/test_checkout_scan_line.py
@@ -295,5 +295,8 @@ def test_scan_line_all_lines_done(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(picking, done=True),
+ "all_processed": True,
+ },
)
diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py
index 0c293e308f..b9d5b870b1 100644
--- a/shopfloor/tests/test_checkout_scan_package_action.py
+++ b/shopfloor/tests/test_checkout_scan_package_action.py
@@ -259,7 +259,10 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self):
response,
# all the lines are packed, so we expect to go the summary screen
next_state="summary",
- data={"picking": self._stock_picking_data(picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(picking, done=True),
+ "all_processed": True,
+ },
message={
"message_type": "info",
"message": "Product(s) packed in {}".format(package.name),
diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py
index 800daf56de..c327ab989a 100644
--- a/shopfloor/tests/test_checkout_select_line.py
+++ b/shopfloor/tests/test_checkout_select_line.py
@@ -108,5 +108,8 @@ def test_select_line_all_lines_done(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(self.picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(self.picking, done=True),
+ "all_processed": True,
+ },
)
diff --git a/shopfloor/tests/test_checkout_summary.py b/shopfloor/tests/test_checkout_summary.py
index 9b0149335d..2d765930a8 100644
--- a/shopfloor/tests/test_checkout_summary.py
+++ b/shopfloor/tests/test_checkout_summary.py
@@ -16,5 +16,8 @@ def test_summary_ok(self):
self.assert_response(
response,
next_state="summary",
- data={"picking": self._stock_picking_data(picking, done=True)},
+ data={
+ "picking": self._stock_picking_data(picking, done=True),
+ "all_processed": True,
+ },
)
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index dfc89c6c2a..b844873408 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -191,6 +191,7 @@ def test_list_batch(self):
"name": self.batch1.name,
"picking_count": 1,
"move_line_count": 1,
+ "weight": 6.0,
},
# batch 2 is excluded because assigned to someone else
{
@@ -198,6 +199,7 @@ def test_list_batch(self):
"name": self.batch3.name,
"picking_count": 1,
"move_line_count": 1,
+ "weight": 6.0,
},
# batch 4 is excluded because not all of its pickings are
# assigned
diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py
index a1a86ca74d..e7e93cc59a 100644
--- a/shopfloor/tests/test_menu.py
+++ b/shopfloor/tests/test_menu.py
@@ -20,9 +20,14 @@ def test_menu_search(self):
{
"id": menu.id,
"name": menu.name,
+ "op_groups": [],
"process": {
"id": menu.process_id.id,
"code": menu.process_id.code,
+ "picking_type": {
+ "id": menu.process_id.picking_type_id.id,
+ "name": menu.process_id.picking_type_id.name,
+ },
},
}
for menu in menus
diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py
index bf76635a82..a68d6e9074 100644
--- a/shopfloor/tests/test_picking_batch.py
+++ b/shopfloor/tests/test_picking_batch.py
@@ -80,18 +80,21 @@ def test_search(self):
"name": self.batch1.name,
"picking_count": 1,
"move_line_count": 1,
+ "weight": 0.0,
},
{
"id": self.batch2.id,
"name": self.batch2.name,
"picking_count": 1,
"move_line_count": 1,
+ "weight": 0.0,
},
{
"id": self.batch3.id,
"name": self.batch3.name,
"picking_count": 1,
"move_line_count": 1,
+ "weight": 0.0,
},
],
},
From e8fb299efc1460be082ae224d806b7bebcfddf72 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 27 Apr 2020 11:16:44 +0200
Subject: [PATCH 190/940] Rework shopfloor datamodel
This is a simplification of the model, which was hard to understand
and configure.
We had too many options to filter the visibility of menus / processes,
which made things hard to debug. Some models were overlapping in term
of concept and fonctionality.
* Processes and menus are redundant, remove processes and keep only menus
* Remove shopfloor groups, we can restrict the visibility of a menu
using profiles. Later, only a manager should be able to change the
profile on the devices.
Additionally, we would still be able to use ir.rules if needed.
* On the menus, we'll be able (still to implement in the services)
to select several picking types. When a scenario has to know a single
picking type, for instance to create a new move, we'll use a dedicated
many2one for this purpose.
---
shopfloor/__manifest__.py | 4 -
shopfloor/demo/shopfloor_menu_demo.xml | 30 ++++++--
.../demo/shopfloor_operation_group_demo.xml | 9 ---
shopfloor/demo/shopfloor_process_demo.xml | 27 -------
shopfloor/demo/shopfloor_profile_demo.xml | 2 -
shopfloor/models/__init__.py | 3 -
shopfloor/models/res_users.py | 10 ---
shopfloor/models/shopfloor_menu.py | 23 +++++-
shopfloor/models/shopfloor_operation_group.py | 12 ---
shopfloor/models/shopfloor_process.py | 23 ------
shopfloor/models/shopfloor_profile.py | 21 +-----
shopfloor/models/stock_move_line.py | 2 +-
shopfloor/models/stock_picking_type.py | 7 +-
shopfloor/security/ir.model.access.csv | 4 -
shopfloor/services/checkout.py | 4 +-
shopfloor/services/cluster_picking.py | 10 +--
shopfloor/services/menu.py | 73 +++++--------------
shopfloor/services/picking_batch.py | 2 +-
shopfloor/services/profile.py | 32 +-------
shopfloor/services/service.py | 20 +++--
shopfloor/services/single_pack_putaway.py | 2 +-
shopfloor/tests/test_app.py | 52 ++++++-------
shopfloor/tests/test_checkout_base.py | 3 +-
shopfloor/tests/test_checkout_select_line.py | 10 +--
shopfloor/tests/test_cluster_picking_base.py | 3 +-
shopfloor/tests/test_delivery_base.py | 3 +-
shopfloor/tests/test_menu.py | 52 ++++++++++---
shopfloor/tests/test_openapi.py | 1 -
shopfloor/tests/test_picking_batch.py | 3 +-
shopfloor/tests/test_single_pack_putaway.py | 7 +-
shopfloor/tests/test_single_pack_transfer.py | 3 +-
shopfloor/views/menus.xml | 12 ---
shopfloor/views/shopfloor_menu.xml | 16 +++-
shopfloor/views/shopfloor_operation_group.xml | 47 ------------
shopfloor/views/shopfloor_process.xml | 50 -------------
shopfloor/views/shopfloor_profile_views.xml | 6 --
shopfloor/views/stock_picking_type.xml | 2 +-
37 files changed, 178 insertions(+), 412 deletions(-)
delete mode 100644 shopfloor/demo/shopfloor_operation_group_demo.xml
delete mode 100644 shopfloor/demo/shopfloor_process_demo.xml
delete mode 100644 shopfloor/models/res_users.py
delete mode 100644 shopfloor/models/shopfloor_operation_group.py
delete mode 100644 shopfloor/models/shopfloor_process.py
delete mode 100644 shopfloor/views/shopfloor_operation_group.xml
delete mode 100644 shopfloor/views/shopfloor_process.xml
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 2342b041d1..bfb02438c8 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -25,9 +25,7 @@
],
"data": [
"security/ir.model.access.csv",
- "views/shopfloor_operation_group.xml",
"views/shopfloor_menu.xml",
- "views/shopfloor_process.xml",
"views/stock_picking_type.xml",
"views/stock_location.xml",
"views/stock_move_line.xml",
@@ -37,9 +35,7 @@
"demo": [
"demo/auth_api_key_demo.xml",
"demo/stock_picking_type_demo.xml",
- "demo/shopfloor_process_demo.xml",
"demo/shopfloor_menu_demo.xml",
- "demo/shopfloor_operation_group_demo.xml",
"demo/shopfloor_profile_demo.xml",
],
}
diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml
index eaf4bb73e0..1276f19a98 100644
--- a/shopfloor/demo/shopfloor_menu_demo.xml
+++ b/shopfloor/demo/shopfloor_menu_demo.xml
@@ -2,26 +2,46 @@
diff --git a/shopfloor/demo/shopfloor_operation_group_demo.xml b/shopfloor/demo/shopfloor_operation_group_demo.xml
deleted file mode 100644
index 8d549c1648..0000000000
--- a/shopfloor/demo/shopfloor_operation_group_demo.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
- HighBay
-
-
-
diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml
deleted file mode 100644
index 0db0a727e4..0000000000
--- a/shopfloor/demo/shopfloor_process_demo.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
- Put-Away Reach Truck
- single_pack_putaway
-
-
-
- Single Pallet Transfer
- single_pack_transfer
-
-
-
- Cluster Picking
- cluster_picking
-
-
-
- Checkout
- checkout
-
-
-
- Delivery
- delivery
-
-
-
diff --git a/shopfloor/demo/shopfloor_profile_demo.xml b/shopfloor/demo/shopfloor_profile_demo.xml
index 743890335c..0275b3e335 100644
--- a/shopfloor/demo/shopfloor_profile_demo.xml
+++ b/shopfloor/demo/shopfloor_profile_demo.xml
@@ -1,12 +1,10 @@
Highbay Truck
-
Shelf 1
-
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index e9f59c02e3..d86b15fec5 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -1,10 +1,7 @@
from . import shopfloor_menu
-from . import shopfloor_operation_group
-from . import shopfloor_process
from . import stock_picking_type
from . import shopfloor_profile
from . import stock_location
from . import stock_move_line
from . import stock_picking_batch
from . import stock_quant_package
-from . import res_users
diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py
deleted file mode 100644
index 8c136f725e..0000000000
--- a/shopfloor/models/res_users.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from odoo import fields, models
-
-
-class ResUsers(models.Model):
- _inherit = "res.users"
-
- # in practice, it's a one2one
- shopfloor_profile_ids = fields.One2many(
- comodel_name="shopfloor.profile", inverse_name="user_id", readonly=True
- )
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index 0f0a56e2bb..c5058c1f56 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -8,8 +8,23 @@ class ShopfloorMenu(models.Model):
name = fields.Char(translate=True)
sequence = fields.Integer()
- operation_group_ids = fields.Many2many(
- "shopfloor.operation.group", string="Groups", help="visible for these groups"
+ profile_ids = fields.Many2many(
+ "shopfloor.profile", string="Profiles", help="Visible for these profiles"
)
- process_id = fields.Many2one("shopfloor.process", name="Process", required=True)
- process_code = fields.Selection(related="process_id.code", readonly=True)
+ picking_type_ids = fields.Many2many(
+ comodel_name="stock.picking.type", string="Operation Types",
+ required=True,
+ )
+ # TODO allow only one picking type when 'move creation' is allowed
+
+ scenario = fields.Selection(selection="_selection_scenario", required=True)
+
+ def _selection_scenario(self):
+ return [
+ # these must match a REST service's '_usage'
+ ("single_pack_putaway", "Single Pack Put-away"),
+ ("single_pack_transfer", "Single Pack Transfer"),
+ ("cluster_picking", "Cluster Picking"),
+ ("checkout", "Checkout/Packing"),
+ ("delivery", "Delivery"),
+ ]
diff --git a/shopfloor/models/shopfloor_operation_group.py b/shopfloor/models/shopfloor_operation_group.py
deleted file mode 100644
index 0dc47fe272..0000000000
--- a/shopfloor/models/shopfloor_operation_group.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from odoo import fields, models
-
-
-class ShopfloorOperationGroup(models.Model):
- _name = "shopfloor.operation.group"
- _description = "Shopfloor operation group, governs which menu items are visible"
-
- name = fields.Char(required=True)
- user_ids = fields.Many2many("res.users", string="Members")
- menu_ids = fields.Many2many(
- "shopfloor.menu", string="Menus", help="Can see these menus"
- )
diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py
deleted file mode 100644
index 2eafef947c..0000000000
--- a/shopfloor/models/shopfloor_process.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from odoo import fields, models
-
-
-class ShopfloorProcess(models.Model):
- _name = "shopfloor.process"
- _description = "a process to be run from the scanners"
-
- name = fields.Char(required=True)
- code = fields.Selection(selection="_selection_code", required=True)
- picking_type_id = fields.Many2one(
- comodel_name="stock.picking.type", string="Operation Type"
- )
- menu_ids = fields.One2many(comodel_name="shopfloor.menu", inverse_name="process_id")
-
- def _selection_code(self):
- return [
- # these must match a REST service's '_usage'
- ("single_pack_putaway", "Single Pack Put-away"),
- ("single_pack_transfer", "Single Pack Transfer"),
- ("cluster_picking", "Cluster Picking"),
- ("checkout", "Checkout"),
- ("delivery", "Delivery"),
- ]
diff --git a/shopfloor/models/shopfloor_profile.py b/shopfloor/models/shopfloor_profile.py
index f18d813b31..ec9e66c792 100644
--- a/shopfloor/models/shopfloor_profile.py
+++ b/shopfloor/models/shopfloor_profile.py
@@ -11,26 +11,9 @@ class ShopfloorProfile(models.Model):
required=True,
default=lambda self: self._default_warehouse_id(),
)
- operation_group_ids = fields.Many2many(
- "shopfloor.operation.group",
- string="Shopfloor Operation Groups",
- help="When unset, all users can use the profile. When set,"
- "only users belonging to at least one group can use the profile.",
+ menu_ids = fields.Many2many(
+ "shopfloor.menu", string="Menus", help="Menus visible for this profile"
)
- user_id = fields.Many2one(
- "res.users",
- copy=False,
- help="Optional user using the profile. When a profile has a"
- "user assigned to it, the user is not allowed to use another profile.",
- )
-
- _sql_constraints = [
- (
- "user_id_uniq",
- "unique(user_id)",
- "A user can be assigned to only one profile.",
- )
- ]
@api.model
def _default_warehouse_id(self):
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index 0ee951644f..ce6d28630d 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -10,7 +10,7 @@ class StockMoveLine(models.Model):
default=False,
copy=False,
help="Technical field. "
- "Indicates if a the move has been postponed in a process.",
+ "Indicates if a the move has been postponed in a barcode scenario.",
)
shopfloor_checkout_done = fields.Boolean(default=False)
diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py
index 80374d89b9..f974b0257a 100644
--- a/shopfloor/models/stock_picking_type.py
+++ b/shopfloor/models/stock_picking_type.py
@@ -4,9 +4,6 @@
class StockPickingType(models.Model):
_inherit = "stock.picking.type"
- process_ids = fields.One2many(
- comodel_name="shopfloor.process",
- inverse_name="picking_type_id",
- string="Shopfloor Processes",
- readonly=True,
+ shopfloor_menu_ids = fields.Many2many(
+ comodel_name="shopfloor.menu", string="Shopfloor Menus", readonly=True,
)
diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv
index 41e5dd07c5..6aca8c733c 100644
--- a/shopfloor/security/ir.model.access.csv
+++ b/shopfloor/security/ir.model.access.csv
@@ -1,9 +1,5 @@
"id","name","model_id/id","group_id/id","perm_read","perm_write","perm_create","perm_unlink"
"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu",,1,0,0,0
"access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager",1,1,1,1
-"access_shopfloor_operation_group_users","shopfloor operation group","model_shopfloor_operation_group",,1,0,0,0
-"access_shopfloor_operation_group_stock_manager","shopfloor operation group inventory manager","model_shopfloor_operation_group","stock.group_stock_manager",1,1,1,1
"access_shopfloor_profile_users","shopfloor profile","model_shopfloor_profile",,1,0,0,0
"access_shopfloor_profile_stock_manager","shopfloor profile inventory manager","model_shopfloor_profile","stock.group_stock_manager",1,1,1,1
-"access_shopfloor_process_users","shopfloor process","model_shopfloor_process",,1,0,0,0
-"access_shopfloor_process_stock_manager","shopfloor process inventory manager","model_shopfloor_process","stock.group_stock_manager",1,1,1,1
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 3d04bc72b8..b5d7dafda9 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -10,7 +10,7 @@ class Checkout(Component):
"""
Methods for the Checkout Process
- This process runs on existing moves.
+ This scenario runs on existing moves.
It happens on the "Packing" step of a pick/pack/ship.
Use cases:
@@ -187,7 +187,7 @@ def _response_for_manual_selection(self, message=None):
return self._response(next_state="manual_selection", data=data, message=message)
def select(self, picking_id):
- """Select a stock picking for the process
+ """Select a stock picking for the scenario
Used from the list of stock pickings (manual_selection), from there,
the user can click on a stock.picking record which calls this method.
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 5de76b4244..842e08d414 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -11,14 +11,14 @@ class ClusterPicking(Component):
"""
Methods for the Cluster Picking Process
- The goal of this process is to do the pickings for a Picking Batch, for
+ The goal of this scenario is to do the pickings for a Picking Batch, for
several customers at once.
The process assumes that picking batch records already exist.
At first, a user gets automatically a batch to work on (assigned to them),
or can select one from a list.
- The process has 2 main phases, which can be done one after the other or a
+ The scenario has 2 main phases, which can be done one after the other or a
bit of both. The first one is picking goods and put them in a roller-cage.
First phase, picking:
@@ -59,7 +59,7 @@ class ClusterPicking(Component):
the move lines will become unavailable or partially unavailable and will
generate a back-order.
* Full bin: declaring a full bin allows to move directly to the first phase
- (picking) to the second one (unload). The process will go
+ (picking) to the second one (unload). The scenario will go
back to the first phase if some lines remain in the queue of lines to pick.
Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
@@ -73,7 +73,7 @@ class ClusterPicking(Component):
def find_batch(self):
"""Find a picking batch to work on and start it
- Usually the starting point of the process.
+ Usually the starting point of the scenario.
Business rules to find a batch, try in order:
@@ -604,7 +604,7 @@ def _are_all_dest_location_same(self, batch):
return len(lines_to_unload.mapped("location_dest_id")) == 1
def prepare_unload(self, picking_batch_id):
- """Initiate the unloading phase of the process
+ """Initiate the unloading phase of the scenario
If the destination of all the move lines still to unload is the same,
it sets the flag ``cluster_picking_unload_all`` to True on
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 839d5634f3..b0322fd4e4 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -8,9 +8,8 @@ class ShopfloorMenu(Component):
"""
Menu Structure for the client application.
- The list of menus is restricted by the operation groups. A menu without
- groups is visible for all users, a menu with group(s) is visible if the
- user is in at least one of the groups.
+ The list of menus is restricted by the profiles.
+ A menu without profile is shown in every profiles.
"""
_inherit = "base.shopfloor.service"
@@ -21,21 +20,16 @@ class ShopfloorMenu(Component):
def _get_base_search_domain(self):
base_domain = super()._get_base_search_domain()
- user = self.env.user
- profile = getattr(self.work, "profile", None)
- op_group_domain = [
- "|",
- ("operation_group_ids", "=", False),
- ("operation_group_ids.user_ids", "=", user.id),
- ]
- if profile:
- # TODO: this probably should be the default only one way
- # What to do w/ profiles linked to specific user?
- # This data model is a bit messy :/
- op_group_domain = [
- ("operation_group_ids", "in", profile.operation_group_ids.ids),
+ return expression.AND(
+ [
+ base_domain,
+ [
+ "|",
+ ("profile_ids", "=", False),
+ ("profile_ids", "in", self.work.profile.id),
+ ],
]
- return expression.AND([base_domain, op_group_domain])
+ )
def _search(self, name_fragment=None):
domain = self._get_base_search_domain()
@@ -45,7 +39,7 @@ def _search(self, name_fragment=None):
return records
def search(self, name_fragment=None):
- """List available menu entries for current user"""
+ """List available menu entries for current profile"""
records = self._search(name_fragment=name_fragment)
return self._response(
data={"size": len(records), "records": self._to_json(records)}
@@ -56,16 +50,10 @@ def _convert_one_record(self, record):
return {
"id": record.id,
"name": record.name,
- "process": {
- "id": record.process_id.id,
- "code": record.process_code,
- "picking_type": {
- "id": record.process_id.picking_type_id.id,
- "name": record.process_id.picking_type_id.name,
- },
- },
- "op_groups": [
- {"id": g.id, "name": g.name} for g in record.operation_group_ids
+ "scenario": record.scenario,
+ "picking_types": [
+ {"id": picking_type.id, "name": picking_type.name}
+ for picking_type in record.picking_type_ids
],
}
@@ -78,9 +66,7 @@ class ShopfloorMenuValidator(Component):
_usage = "menu.validator"
def search(self):
- return {
- "name_fragment": {"type": "string", "nullable": True, "required": False}
- }
+ return {"name_fragment": {"type": "string", "nullable": True, "required": False}}
class ShopfloorMenuValidatorResponse(Component):
@@ -107,32 +93,13 @@ def _record_schema(self):
return {
"id": {"coerce": to_int, "required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
- "op_groups": {
+ "scenario": {"type": "string", "nullable": False, "required": True},
+ "picking_types": {
"type": "list",
- "required": True,
- "schema": {"type": "dict", "schema": self._op_group_schema},
- },
- "process": {
- "type": "dict",
- "required": True,
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "code": {"type": "string", "nullable": False, "required": True},
- "picking_type": {
- "type": "dict",
- "schema": self._picking_type_schema,
- },
- },
+ "schema": {"type": "dict", "schema": self._picking_type_schema},
},
}
- @property
- def _op_group_schema(self):
- return {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- }
-
@property
def _picking_type_schema(self):
return {
diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py
index de5086ddf0..e86fcf0e2d 100644
--- a/shopfloor/services/picking_batch.py
+++ b/shopfloor/services/picking_batch.py
@@ -61,7 +61,7 @@ def search(self, name_fragment=None):
"""List available stock picking batches for current user
Show only picking batches where all the pickings are available and
- where all pickings are in the picking type of the current process.
+ where all pickings are in the picking type of the current scenario.
"""
records = self._search(name_fragment=name_fragment)
return self._response(
diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py
index 94ee11d5ed..af9b9d2841 100644
--- a/shopfloor/services/profile.py
+++ b/shopfloor/services/profile.py
@@ -1,6 +1,3 @@
-from odoo import fields
-from odoo.osv import expression
-
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -10,13 +7,9 @@ class ShopfloorProfile(Component):
Profile storing the configuration for the interaction from the client.
A client application must use a profile, passed to every request in the
- HTTP header (TODO put the name of the header).
-
- The list of profiles available for a user is restricted by 2 things:
+ HTTP header HTTP_SERVICE_CTX_PROFILE_ID.
- * If the profile has operation groups, the profile can be used only
- if the user is at least in one of these groups.
- * If the user has an assigned profile, the user can use only this profile.
+ Only stock managers should be allowed to change the profile for a device.
"""
_inherit = "base.shopfloor.service"
@@ -33,31 +26,12 @@ def _search(self, name_fragment=None):
return records
def search(self, name_fragment=None):
- """List available profiles for current user"""
+ """List available profiles"""
records = self._search(name_fragment=name_fragment)
return self._response(
data={"size": len(records), "records": self._to_json(records)}
)
- def _get_base_search_domain(self):
- # shopfloor_profile_ids is a one2one in practice.
- base_domain = super()._get_base_search_domain()
- user = self.env.user
- assigned_profile = fields.first(user.shopfloor_profile_ids)
- if assigned_profile:
- return expression.AND([base_domain, [("id", "=", assigned_profile.id)]])
-
- return expression.AND(
- [
- base_domain,
- [
- "|",
- ("operation_group_ids", "=", False),
- ("operation_group_ids.user_ids", "=", user.id),
- ],
- ]
- )
-
def _convert_one_record(self, record):
return {
"id": record.id,
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 46e2d63bde..e12c22195a 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -28,10 +28,9 @@ def picking_type(self):
"""
Get the current picking type based on the menu and the warehouse of the profile.
"""
- # TODO: this validation should be done in the service
- # and should return a proper error for the client
- # or the client should handle exceptions properly.
- picking_type = self.work.menu.process_id.picking_type_id
+ # TODO adapt scenarios to handle multiple types, rename this to picking_types
+ # and filter by warehouse of the profile
+ picking_type = self.work.menu.picking_type_ids
if picking_type.warehouse_id != self.work.profile.warehouse_id:
raise exceptions.UserError(
_("Process {} cannot be used on warehouse {}").format(
@@ -74,9 +73,7 @@ def _get_input_validator(self, method_name):
def _get_output_validator(self, method_name):
# override the method to get the validator in a component
# instead of a method, to keep things apart
- validator_component = self.component(
- usage="%s.validator.response" % self._usage
- )
+ validator_component = self.component(usage="%s.validator.response" % self._usage)
return validator_component._get_validator(method_name)
def _response(self, base_response=None, data=None, next_state=None, message=None):
@@ -126,10 +123,11 @@ def _get_openapi_default_parameters(self):
demo_api_key = self.env.ref("shopfloor.api_key_demo", raise_if_not_found=False)
# Try to first the first menu that implements the current service.
- # Not all usages have a process, in that case, well set the first
- # process found, because it should not matter for the service.
- processes = self.env["shopfloor.process"].search([("code", "=", self._usage)])
- menu = fields.first(processes.menu_ids)
+ # Not all usages have a process, in that case, we'll set the first
+ # menu found
+ menu = self.env["shopfloor.menu"].search(
+ [("scenario", "=", self._usage)], limit=1
+ )
if not menu:
menu = self.env["shopfloor.menu"].search([], limit=1)
profile = self.env["shopfloor.profile"].search([], limit=1)
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 73f4e5fdab..22047fb382 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -44,7 +44,7 @@ def _response_for_forbidden_start(self, existing_operations):
"message_type": "error",
"message": _(
"An operation exists in %s %s. "
- "You cannot process it with this shopfloor process."
+ "You cannot process it with this shopfloor scenario."
)
% (
existing_operations[0].picking_id.picking_type_id.name,
diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py
index c3cab5356a..dcfada2fb7 100644
--- a/shopfloor/tests/test_app.py
+++ b/shopfloor/tests/test_app.py
@@ -2,9 +2,15 @@
class AppCase(CommonCase):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_hb_truck_demo")
+ cls.profile2 = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+
def setUp(self):
super().setUp()
- with self.work_on_services() as work:
+ with self.work_on_services(profile=self.profile) as work:
self.service = work.component(usage="app")
def test_user_config(self):
@@ -41,38 +47,26 @@ def test_menu_no_profile(self):
{
"id": menu.id,
"name": menu.name,
- "op_groups": [
- {"id": x.id, "name": x.name}
- for x in menu.operation_group_ids
+ "scenario": menu.scenario,
+ "picking_types": [
+ {"id": picking_type.id, "name": picking_type.name}
+ for picking_type in menu.picking_type_ids
],
- "process": {
- "id": menu.process_id.id,
- "code": menu.process_id.code,
- "picking_type": {
- "id": menu.process_id.picking_type_id.id,
- "name": menu.process_id.picking_type_id.name,
- },
- },
}
for menu in menus
- ],
+ ]
},
)
def test_menu_by_profile(self):
"""Request /app/menu w/ a specific profile"""
# Simulate the client asking the menu
- op_type = self.env.ref("shopfloor.shopfloor_operation_group_highbay_demo")
menus = self.env["shopfloor.menu"].search([])
menu = menus[0]
- menu.operation_group_ids = op_type
- profile = self.env.ref("shopfloor.shopfloor_profile_hb_truck_demo")
- profile.operation_group_ids = op_type
-
- with self.work_on_services(profile=profile) as work:
- service = work.component(usage="app")
+ menu.profile_ids = self.profile
+ (menus - menu).profile_ids = self.profile2
- response = service.dispatch("menu")
+ response = self.service.dispatch("menu")
self.assert_response(
response,
data={
@@ -80,16 +74,12 @@ def test_menu_by_profile(self):
{
"id": menu.id,
"name": menu.name,
- "op_groups": [{"id": op_type.id, "name": op_type.name}],
- "process": {
- "id": menu.process_id.id,
- "code": menu.process_id.code,
- "picking_type": {
- "id": menu.process_id.picking_type_id.id,
- "name": menu.process_id.picking_type_id.name,
- },
- },
+ "scenario": menu.scenario,
+ "picking_types": [
+ {"id": picking_type.id, "name": picking_type.name}
+ for picking_type in menu.picking_type_ids
+ ],
}
- ],
+ ]
},
)
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index 1f09b9d0a3..16a2123148 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -6,11 +6,10 @@ class CheckoutCommonCase(CommonCase):
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout")
- cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
cls.wh.delivery_steps = "pick_pack_ship"
- cls.picking_type = cls.process.picking_type_id
+ cls.picking_type = cls.menu.picking_type_ids
def setUp(self):
super().setUp()
diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py
index c327ab989a..8e6ecc69bc 100644
--- a/shopfloor/tests/test_checkout_select_line.py
+++ b/shopfloor/tests/test_checkout_select_line.py
@@ -37,10 +37,7 @@ def test_select_line_move_line_package_ok(self):
# a package and use the move line id only for lines without package
response = self.service.dispatch(
"select_line",
- params={
- "picking_id": self.picking.id,
- "move_line_id": selected_lines[0].id,
- },
+ params={"picking_id": self.picking.id, "move_line_id": selected_lines[0].id},
)
self._assert_selected(response, selected_lines)
@@ -48,10 +45,7 @@ def test_select_line_move_line_ok(self):
selected_lines = self.move_single.move_line_ids
response = self.service.dispatch(
"select_line",
- params={
- "picking_id": self.picking.id,
- "move_line_id": selected_lines[0].id,
- },
+ params={"picking_id": self.picking.id, "move_line_id": selected_lines[0].id},
)
self._assert_selected(response, selected_lines)
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index 7463a7e0b2..aaa3be424a 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -6,11 +6,10 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin):
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
- cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
cls.wh.delivery_steps = "pick_pack_ship"
- cls.picking_type = cls.process.picking_type_id
+ cls.picking_type = cls.menu.picking_type_ids
def setUp(self):
super().setUp()
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
index 27d645a703..564960a4c4 100644
--- a/shopfloor/tests/test_delivery_base.py
+++ b/shopfloor/tests/test_delivery_base.py
@@ -7,11 +7,10 @@ def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery")
- cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
cls.wh.delivery_steps = "pick_pack_ship"
- cls.picking_type = cls.process.picking_type_id
+ cls.picking_type = cls.menu.picking_type_ids
def setUp(self):
super().setUp()
diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py
index e7e93cc59a..45cc68e47c 100644
--- a/shopfloor/tests/test_menu.py
+++ b/shopfloor/tests/test_menu.py
@@ -2,9 +2,14 @@
class MenuCase(CommonCase):
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+
def setUp(self):
super().setUp()
- with self.work_on_services() as work:
+ with self.work_on_services(profile=self.profile) as work:
self.service = work.component(usage="menu")
def test_menu_search(self):
@@ -20,17 +25,44 @@ def test_menu_search(self):
{
"id": menu.id,
"name": menu.name,
- "op_groups": [],
- "process": {
- "id": menu.process_id.id,
- "code": menu.process_id.code,
- "picking_type": {
- "id": menu.process_id.picking_type_id.id,
- "name": menu.process_id.picking_type_id.name,
- },
- },
+ "scenario": menu.scenario,
+ "picking_types": [
+ {"id": picking_type.id, "name": picking_type.name}
+ for picking_type in menu.picking_type_ids
+ ],
}
for menu in menus
],
},
)
+
+ def test_menu_search_restricted(self):
+ """Request /menu/search with profile attributions"""
+ # Simulate the client searching menus
+ menus = self.env["shopfloor.menu"].search([])
+ menus_without_profile = menus[0:2]
+ # these menus should now be hidden for the current profile
+ other_profile = self.env.ref("shopfloor.shopfloor_profile_hb_truck_demo")
+ menus_without_profile.profile_ids = other_profile
+
+ response = self.service.dispatch("search")
+
+ my_menus = menus - menus_without_profile
+ self.assert_response(
+ response,
+ data={
+ "size": len(my_menus),
+ "records": [
+ {
+ "id": menu.id,
+ "name": menu.name,
+ "scenario": menu.scenario,
+ "picking_types": [
+ {"id": picking_type.id, "name": picking_type.name}
+ for picking_type in menu.picking_type_ids
+ ],
+ }
+ for menu in my_menus
+ ],
+ },
+ )
diff --git a/shopfloor/tests/test_openapi.py b/shopfloor/tests/test_openapi.py
index 5eff2b327c..1eae2f5569 100644
--- a/shopfloor/tests/test_openapi.py
+++ b/shopfloor/tests/test_openapi.py
@@ -9,7 +9,6 @@ def setUpClass(cls, *args, **kwargs):
# we don't really care about which menu and profile we use
# to read the OpenAPI specs
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery")
- cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
def setUp(self):
diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_picking_batch.py
index a68d6e9074..0eb20edc82 100644
--- a/shopfloor/tests/test_picking_batch.py
+++ b/shopfloor/tests/test_picking_batch.py
@@ -13,10 +13,9 @@ def setUpClass(cls, *args, **kwargs):
)
# which menu we pick should not matter for the batch picking api
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
- cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
- cls.picking_type = cls.process.picking_type_id
+ cls.picking_type = cls.menu.picking_type_ids
cls.batch1 = cls._create_picking_batch(
[[cls.BatchProduct(product=cls.product_a, quantity=1)]]
)
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index e943dfe953..f28c329a4a 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -29,7 +29,6 @@ def setUpClass(cls, *args, **kwargs):
}
)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck")
- cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
@@ -141,7 +140,7 @@ def test_start_package_not_in_src_location(self):
"message": "You cannot work on a package (%s) outside of location: %s"
% (
self.pack_a.name,
- self.process.picking_type_id.default_location_src_id.name,
+ self.menu.picking_type_ids.default_location_src_id.name,
),
},
)
@@ -180,7 +179,7 @@ def test_start_move_in_different_picking_type(self):
message={
"message_type": "error",
"message": "An operation exists in Delivery Orders %s. You cannot"
- " process it with this shopfloor process." % (picking.name,),
+ " process it with this shopfloor scenario." % (picking.name,),
},
)
@@ -235,7 +234,7 @@ def _simulate_started(self):
Used to test the next endpoints (/validate and /cancel)
"""
picking_form = Form(self.env["stock.picking"])
- picking_form.picking_type_id = self.menu.process_id.picking_type_id
+ picking_form.picking_type_id = self.menu.picking_type_ids
with picking_form.move_ids_without_package.new() as move:
move.product_id = self.product_a
move.product_uom_qty = 1
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 5a91659502..6270163224 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -22,10 +22,9 @@ def setUpClass(cls, *args, **kwargs):
}
)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_single_pallet_transfer")
- cls.process = cls.menu.process_id
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
- cls.picking_type = cls.process.picking_type_id
+ cls.picking_type = cls.menu.picking_type_ids
cls.picking = cls._create_initial_move()
# disable the completion on the picking type, we'll have specific test(s)
diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml
index 6ece0c498f..29876a3a77 100644
--- a/shopfloor/views/menus.xml
+++ b/shopfloor/views/menus.xml
@@ -12,18 +12,6 @@
parent="menu_shopfloor_settings"
sequence="10"
/>
-
-
@@ -18,6 +27,9 @@
+
+
+
diff --git a/shopfloor/views/shopfloor_operation_group.xml b/shopfloor/views/shopfloor_operation_group.xml
deleted file mode 100644
index d4286df223..0000000000
--- a/shopfloor/views/shopfloor_operation_group.xml
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-
- shopfloor operation group tree
- shopfloor.operation.group
-
-
-
-
-
-
-
-
- shopfloor operation group form
- shopfloor.operation.group
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- shopfloor operation group search
- shopfloor.operation.group
-
-
-
-
-
-
-
- Operation Groups
- shopfloor.operation.group
- ir.actions.act_window
- tree,form
-
-
diff --git a/shopfloor/views/shopfloor_process.xml b/shopfloor/views/shopfloor_process.xml
deleted file mode 100644
index 5ec7e2fb58..0000000000
--- a/shopfloor/views/shopfloor_process.xml
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
-
- shopfloor process tree
- shopfloor.process
-
-
-
-
-
-
-
-
-
- shopfloor process form
- shopfloor.process
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- shopfloor process search
- shopfloor.process
-
-
-
-
-
-
-
-
-
- Processes
- shopfloor.process
- ir.actions.act_window
- tree,form
-
-
diff --git a/shopfloor/views/shopfloor_profile_views.xml b/shopfloor/views/shopfloor_profile_views.xml
index 5f0fd5245b..785aa6231d 100644
--- a/shopfloor/views/shopfloor_profile_views.xml
+++ b/shopfloor/views/shopfloor_profile_views.xml
@@ -7,8 +7,6 @@
-
-
@@ -24,8 +22,6 @@
-
-
@@ -39,8 +35,6 @@
-
-
diff --git a/shopfloor/views/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml
index 7d38e3074f..25313e1586 100644
--- a/shopfloor/views/stock_picking_type.xml
+++ b/shopfloor/views/stock_picking_type.xml
@@ -6,7 +6,7 @@
-
+
From fdc250d67242f155155fdf0f37b1e0db67c95503 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 5 May 2020 15:04:02 +0200
Subject: [PATCH 191/940] checkout: modify lookup by package
The test CheckoutScanCase.test_scan_document_error_different_picking_type_package
was no longer passing because was returning a "barcode not
found" instead of "you cannot move this using this menu".
The picking type should not be checked at this point, as it is checked
in _select_picking() (called at the end of the method), which will
return the appropriate message.
Note: replaced the search by a field to reuse the cache if we had such
data in cache.
---
shopfloor/actions/search.py | 13 -------------
shopfloor/models/stock_quant_package.py | 7 +++++++
shopfloor/services/checkout.py | 13 ++++++++++++-
shopfloor/tests/test_checkout_scan.py | 12 +++++++-----
4 files changed, 26 insertions(+), 19 deletions(-)
diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py
index 2bba15131d..a547abbbc6 100644
--- a/shopfloor/actions/search.py
+++ b/shopfloor/actions/search.py
@@ -37,16 +37,3 @@ def generic_packaging_from_scan(self, barcode):
return self.env["product.packaging"].search(
[("barcode", "=", barcode), ("product_id", "=", False)]
)
-
- def stock_picking_from_package(self, package, picking_type):
- return (
- self.env["stock.move.line"]
- .search(
- [
- ("state", "not in", ("cancel", "done")),
- ("package_id", "=", package.id),
- ("picking_id.picking_type_id", "=", picking_type.id),
- ]
- )
- .mapped("picking_id")
- )
diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py
index c835935fb3..4716a95bb1 100644
--- a/shopfloor/models/stock_quant_package.py
+++ b/shopfloor/models/stock_quant_package.py
@@ -4,6 +4,13 @@
class StockQuantPackage(models.Model):
_inherit = "stock.quant.package"
+ move_line_ids = fields.One2many(
+ comodel_name="stock.move.line",
+ inverse_name="package_id",
+ readonly=True,
+ help="Technical field. Move lines moving this package.",
+ )
+
planned_move_line_ids = fields.One2many(
comodel_name="stock.move.line",
inverse_name="result_package_id",
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index b5d7dafda9..265bba4dec 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -85,7 +85,18 @@ def scan_document(self, barcode):
if not picking:
package = search.package_from_scan(barcode)
if package:
- pickings = search.stock_picking_from_package(package, self.picking_type)
+ pickings = package.move_line_ids.filtered(
+ lambda ml: ml.state not in ("cancel", "done")
+
+ ).mapped("picking_id")
+ 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
+ # 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 == self.picking_type)
if len(pickings) == 1:
picking = pickings
return self._select_picking(picking, "select_document")
diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py
index 4972a278cd..43ba2cc6dd 100644
--- a/shopfloor/tests/test_checkout_scan.py
+++ b/shopfloor/tests/test_checkout_scan.py
@@ -80,7 +80,7 @@ def test_scan_document_error_location_not_child_of_type(self):
message={"message_type": "error", "message": "Location not allowed here."},
)
- def _test_scan_document_error_different_picking_type(self, barcode_func, msg):
+ def _test_scan_document_error_different_picking_type(self, barcode_func):
picking = self._create_picking(picking_type=self.wh.pick_type_id)
self._fill_stock_for_moves(picking.move_lines, in_package=True)
picking.action_assign()
@@ -89,18 +89,20 @@ def _test_scan_document_error_different_picking_type(self, barcode_func, msg):
self.assert_response(
response,
next_state="select_document",
- message={"message_type": "error", "message": msg},
+ message={
+ "message_type": "error",
+ "message": "You cannot move this using this menu.",
+ },
)
def test_scan_document_error_different_picking_type_picking(self):
self._test_scan_document_error_different_picking_type(
- lambda picking: picking.name, msg="You cannot move this using this menu."
+ lambda picking: picking.name
)
def test_scan_document_error_different_picking_type_package(self):
self._test_scan_document_error_different_picking_type(
- lambda picking: picking.move_line_ids.package_id.name,
- msg="Barcode not found",
+ lambda picking: picking.move_line_ids.package_id.name
)
def test_scan_document_error_location_several_pickings(self):
From 53e4441ed32bb98de1d87e7569922607c772a23f Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 5 May 2020 15:51:03 +0200
Subject: [PATCH 192/940] backend: support multiple picking types in services
Except for the cases where we can create a move, a single menu should
be able to work on several picking types.
Later, some menus will have a flag "create_move", which add an extra
step in the scenario. In this case, we'll still have a selection of
several picking types on the menu, but an additional M2o to select the
picking type used for creation of moves.
---
shopfloor/actions/message.py | 6 ++---
shopfloor/models/stock_location.py | 5 ++--
shopfloor/services/checkout.py | 8 +++----
shopfloor/services/menu.py | 2 ++
shopfloor/services/picking_batch.py | 2 +-
shopfloor/services/service.py | 22 ++++++++++--------
shopfloor/services/single_pack_putaway.py | 8 +++++--
shopfloor/services/single_pack_transfer.py | 24 +++++++-------------
shopfloor/tests/test_single_pack_putaway.py | 2 +-
shopfloor/tests/test_single_pack_transfer.py | 2 +-
10 files changed, 41 insertions(+), 40 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index b2b6cc8433..d7166ece3e 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -38,11 +38,11 @@ def package_not_found_for_barcode(self, barcode):
def bin_not_found_for_barcode(self, barcode):
return {"message_type": "error", "message": _("Bin %s doesn't exist") % barcode}
- def package_not_allowed_in_src_location(self, barcode, picking_type):
+ def package_not_allowed_in_src_location(self, barcode, picking_types):
return {
"message_type": "error",
- "message": _("You cannot work on a package (%s) outside of location: %s")
- % (barcode, picking_type.default_location_src_id.name),
+ "message": _("You cannot work on a package (%s) outside of locations: %s")
+ % (barcode, ", ".join(picking_types.mapped("default_location_src_id.name"))),
}
def already_running_ask_confirmation(self):
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
index 4e391e629d..6c9ce5962a 100644
--- a/shopfloor/models/stock_location.py
+++ b/shopfloor/models/stock_location.py
@@ -8,10 +8,11 @@ class StockLocation(models.Model):
comodel_name="stock.move.line", inverse_name="location_id", readonly=True
)
- def is_sublocation_of(self, other):
+ def is_sublocation_of(self, others):
+ """Return True if self is a sublocation of at least one other"""
self.ensure_one()
return bool(
self.env["stock.location"].search_count(
- [("id", "child_of", other.id), ("id", "=", self.id)]
+ [("id", "child_of", others.ids), ("id", "=", self.id)]
)
)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 265bba4dec..c48b5b9b70 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -63,7 +63,7 @@ def scan_document(self, barcode):
location = search.location_from_scan(barcode)
if location:
if not location.is_sublocation_of(
- self.picking_type.default_location_src_id
+ self.picking_types.mapped("default_location_src_id")
):
return self._response_for_select_document(
message=message.location_not_allowed()
@@ -96,7 +96,7 @@ def scan_document(self, barcode):
# 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 == self.picking_type)
+ pickings = pickings.filtered(lambda p: p.picking_type_id in self.picking_types)
if len(pickings) == 1:
picking = pickings
return self._select_picking(picking, "select_document")
@@ -111,7 +111,7 @@ def _select_picking(self, picking, state_for_error):
return self._response_for_select_document(
message=message.barcode_not_found()
)
- if picking.picking_type_id != self.picking_type:
+ if picking.picking_type_id not in self.picking_types:
if state_for_error == "manual_selection":
return self._response_for_manual_selection(
message=message.cannot_move_something_in_picking_type()
@@ -169,7 +169,7 @@ def _lines_to_pack(self, picking):
def _domain_for_list_stock_picking(self):
return [
("state", "=", "assigned"),
- ("picking_type_id", "=", self.picking_type.id),
+ ("picking_type_id", "in", self.picking_types.ids),
]
def _order_for_list_stock_picking(self):
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index b0322fd4e4..a54a62615b 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -20,6 +20,8 @@ class ShopfloorMenu(Component):
def _get_base_search_domain(self):
base_domain = super()._get_base_search_domain()
+ # FIXME exclude menus if they have no picking types
+ # in warehouse matching the profile
return expression.AND(
[
base_domain,
diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py
index e86fcf0e2d..44de8eb7e8 100644
--- a/shopfloor/services/picking_batch.py
+++ b/shopfloor/services/picking_batch.py
@@ -51,7 +51,7 @@ def _search(self, name_fragment=None, batch_ids=None):
batch.state == "in_progress"
or picking.state in ("assigned", "done", "cancel")
)
- and picking.picking_type_id == self.picking_type
+ and picking.picking_type_id in self.picking_types
for picking in batch.picking_ids
)
)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index e12c22195a..6d2bdfb7d8 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -24,21 +24,23 @@ class BaseShopfloorService(AbstractComponent):
_expose_model = None
@property
- def picking_type(self):
+ def picking_types(self):
"""
- Get the current picking type based on the menu and the warehouse of the profile.
+ Get the allowed picking types based on the menu and the warehouse of the profile.
"""
- # TODO adapt scenarios to handle multiple types, rename this to picking_types
- # and filter by warehouse of the profile
- picking_type = self.work.menu.picking_type_ids
- if picking_type.warehouse_id != self.work.profile.warehouse_id:
+ # TODO make this a lazy property or computed field avoid running the
+ # filter every time?
+ picking_types = self.work.menu.picking_type_ids.filtered(
+ lambda pt: not pt.warehouse_id
+ or pt.warehouse_id == self.work.profile.warehouse_id
+ )
+ if not picking_types:
raise exceptions.UserError(
- _("Process {} cannot be used on warehouse {}").format(
- picking_type.display_name,
- self.work.profile.warehouse_id.display_name,
+ _("No operation types configured on menu {} for warehouse {}.").format(
+ self.work.menu.name, self.work.profile.warehouse_id.display_name
)
)
- return picking_type
+ return picking_types
def _get(self, _id):
domain = expression.normalize_domain(self._get_base_search_domain())
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 22047fb382..9b312ea2ee 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -85,8 +85,12 @@ def _response_for_start_success(self, move_line, pack):
def start(self, barcode):
"""Scan a pack barcode"""
-
- picking_type = self.picking_type
+ # TODO we have to rework this and single_pack_transfer, 'pack putaway'
+ # will be integrated within 'pack transfer' with an option "create
+ # move" on the menu. When "create move" is active on the menu, an
+ # additional M2o field must be filled on the menu with the picking type
+ # used for creations.
+ picking_type = self.picking_types
if len(picking_type) > 1:
return self._response_for_several_picking_types()
elif not picking_type:
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index d2db50456f..2deccf5fc9 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -32,17 +32,11 @@ def _response_for_package_not_found(self, barcode):
next_state="start", message=message.package_not_found_for_barcode(barcode)
)
- def _response_for_forbidden_package(self, barcode, picking_type):
+ def _response_for_forbidden_package(self, barcode, picking_types):
message = self.actions_for("message")
return self._response(
next_state="start",
- message=message.package_not_allowed_in_src_location(barcode, picking_type),
- )
-
- def _response_for_several_picking_types(self):
- message = self.actions_for("message")
- return self._response(
- next_state="start", message=message.several_picking_types()
+ message=message.package_not_allowed_in_src_location(barcode, picking_types),
)
def _response_for_operation_not_found(self, pack):
@@ -81,11 +75,7 @@ def _response_for_start_success(self, move_line, pack):
def start(self, barcode):
search = self.actions_for("search")
-
- picking_type = self.picking_type
- if len(picking_type) > 1:
- return self._response_for_several_picking_types()
-
+ picking_types = self.picking_types
location = search.location_from_scan(barcode)
pack = self.env["stock.quant.package"]
@@ -104,14 +94,16 @@ def start(self, barcode):
if not pack:
return self._response_for_package_not_found(barcode)
- if not pack.location_id.is_sublocation_of(picking_type.default_location_src_id):
- return self._response_for_forbidden_package(barcode, picking_type)
+ if not pack.location_id.is_sublocation_of(
+ picking_types.mapped("default_location_src_id")
+ ):
+ return self._response_for_forbidden_package(barcode, picking_types)
existing_operations = self.env["stock.move.line"].search(
[
("package_id", "=", pack.id),
("state", "!=", "done"),
- ("picking_id.picking_type_id", "=", self.picking_type.id),
+ ("picking_id.picking_type_id", "in", picking_types.ids),
]
)
if not existing_operations:
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index f28c329a4a..6428638418 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -137,7 +137,7 @@ def test_start_package_not_in_src_location(self):
next_state="start",
message={
"message_type": "error",
- "message": "You cannot work on a package (%s) outside of location: %s"
+ "message": "You cannot work on a package (%s) outside of locations: %s"
% (
self.pack_a.name,
self.menu.picking_type_ids.default_location_src_id.name,
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 6270163224..7cb8ff577d 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -273,7 +273,7 @@ def test_start_pack_outside_of_location(self):
next_state="start",
message={
"message_type": "error",
- "message": "You cannot work on a package (%s) outside of location: %s"
+ "message": "You cannot work on a package (%s) outside of locations: %s"
% (self.pack_a.name, self.picking_type.default_location_src_id.name),
},
)
From 13f3aecc4f69389ef6b587f7b6e7734d0d78f721 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 5 May 2020 15:57:21 +0200
Subject: [PATCH 193/940] run black
---
shopfloor/actions/message.py | 5 ++++-
shopfloor/models/shopfloor_menu.py | 3 +--
shopfloor/services/checkout.py | 5 +++--
shopfloor/services/menu.py | 4 +++-
shopfloor/services/service.py | 10 +++++-----
shopfloor/tests/test_checkout_cancel_line.py | 4 ++--
shopfloor/tests/test_checkout_select_line.py | 10 ++++++++--
7 files changed, 26 insertions(+), 15 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index d7166ece3e..e035a3fdaa 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -42,7 +42,10 @@ def package_not_allowed_in_src_location(self, barcode, picking_types):
return {
"message_type": "error",
"message": _("You cannot work on a package (%s) outside of locations: %s")
- % (barcode, ", ".join(picking_types.mapped("default_location_src_id.name"))),
+ % (
+ barcode,
+ ", ".join(picking_types.mapped("default_location_src_id.name")),
+ ),
}
def already_running_ask_confirmation(self):
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index c5058c1f56..0074c72999 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -12,8 +12,7 @@ class ShopfloorMenu(models.Model):
"shopfloor.profile", string="Profiles", help="Visible for these profiles"
)
picking_type_ids = fields.Many2many(
- comodel_name="stock.picking.type", string="Operation Types",
- required=True,
+ comodel_name="stock.picking.type", string="Operation Types", required=True,
)
# TODO allow only one picking type when 'move creation' is allowed
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index c48b5b9b70..27ca05f3ab 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -87,7 +87,6 @@ def scan_document(self, barcode):
if package:
pickings = package.move_line_ids.filtered(
lambda ml: ml.state not in ("cancel", "done")
-
).mapped("picking_id")
if len(pickings) > 1:
# Filter only if we find several pickings to narrow the
@@ -96,7 +95,9 @@ def scan_document(self, barcode):
# 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)
+ pickings = pickings.filtered(
+ lambda p: p.picking_type_id in self.picking_types
+ )
if len(pickings) == 1:
picking = pickings
return self._select_picking(picking, "select_document")
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index a54a62615b..3c0321b23f 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -68,7 +68,9 @@ class ShopfloorMenuValidator(Component):
_usage = "menu.validator"
def search(self):
- return {"name_fragment": {"type": "string", "nullable": True, "required": False}}
+ return {
+ "name_fragment": {"type": "string", "nullable": True, "required": False}
+ }
class ShopfloorMenuValidatorResponse(Component):
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 6d2bdfb7d8..ba4b867ddb 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -1,4 +1,4 @@
-from odoo import _, exceptions, fields
+from odoo import _, exceptions
from odoo.exceptions import MissingError
from odoo.osv import expression
@@ -25,9 +25,7 @@ class BaseShopfloorService(AbstractComponent):
@property
def picking_types(self):
- """
- Get the allowed picking types based on the menu and the warehouse of the profile.
- """
+ """Return picking types for the menu and profile"""
# TODO make this a lazy property or computed field avoid running the
# filter every time?
picking_types = self.work.menu.picking_type_ids.filtered(
@@ -75,7 +73,9 @@ def _get_input_validator(self, method_name):
def _get_output_validator(self, method_name):
# override the method to get the validator in a component
# instead of a method, to keep things apart
- validator_component = self.component(usage="%s.validator.response" % self._usage)
+ validator_component = self.component(
+ usage="%s.validator.response" % self._usage
+ )
return validator_component._get_validator(method_name)
def _response(self, base_response=None, data=None, next_state=None, message=None):
diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py
index cbe9aee027..5f0589c73c 100644
--- a/shopfloor/tests/test_checkout_cancel_line.py
+++ b/shopfloor/tests/test_checkout_cancel_line.py
@@ -86,7 +86,7 @@ def test_cancel_package_ok(self):
response,
next_state="summary",
data={
- "picking": self._stock_picking_data(self.picking, done=True),
+ "picking": self._stock_picking_data(picking, done=True),
"all_processed": False,
},
)
@@ -111,7 +111,7 @@ def test_cancel_line_ok(self):
response,
next_state="summary",
data={
- "picking": self._stock_picking_data(self.picking, done=True),
+ "picking": self._stock_picking_data(picking, done=True),
"all_processed": False,
},
)
diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py
index 8e6ecc69bc..c327ab989a 100644
--- a/shopfloor/tests/test_checkout_select_line.py
+++ b/shopfloor/tests/test_checkout_select_line.py
@@ -37,7 +37,10 @@ def test_select_line_move_line_package_ok(self):
# a package and use the move line id only for lines without package
response = self.service.dispatch(
"select_line",
- params={"picking_id": self.picking.id, "move_line_id": selected_lines[0].id},
+ params={
+ "picking_id": self.picking.id,
+ "move_line_id": selected_lines[0].id,
+ },
)
self._assert_selected(response, selected_lines)
@@ -45,7 +48,10 @@ def test_select_line_move_line_ok(self):
selected_lines = self.move_single.move_line_ids
response = self.service.dispatch(
"select_line",
- params={"picking_id": self.picking.id, "move_line_id": selected_lines[0].id},
+ params={
+ "picking_id": self.picking.id,
+ "move_line_id": selected_lines[0].id,
+ },
)
self._assert_selected(response, selected_lines)
From 334f06530e4eee3773d3de6a0c4b143f6e7952b7 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 6 May 2020 08:35:31 +0200
Subject: [PATCH 194/940] backend: hide menus with picking types of different
warehouse
---
shopfloor/services/menu.py | 15 ++++++++---
shopfloor/tests/test_menu.py | 48 +++++++++++++++++++-----------------
2 files changed, 37 insertions(+), 26 deletions(-)
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 3c0321b23f..3d9bad9d98 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -20,24 +20,33 @@ class ShopfloorMenu(Component):
def _get_base_search_domain(self):
base_domain = super()._get_base_search_domain()
- # FIXME exclude menus if they have no picking types
- # in warehouse matching the profile
return expression.AND(
[
base_domain,
[
"|",
("profile_ids", "=", False),
- ("profile_ids", "in", self.work.profile.id),
+ ("profile_ids", "in", self.work.profile.ids),
],
]
)
def _search(self, name_fragment=None):
+ if not self.work.profile:
+ # we need to know the warehouse of the profile
+ # to load menus
+ return self.env["shopfloor.menu"].browse()
domain = self._get_base_search_domain()
if name_fragment:
domain.append(("name", "ilike", name_fragment))
records = self.env[self._expose_model].search(domain)
+ current_wh = self.work.profile.warehouse_id
+ records = records.filtered(
+ lambda menu: all(
+ not pt.warehouse_id or pt.warehouse_id == current_wh
+ for pt in menu.picking_type_ids
+ )
+ )
return records
def search(self, name_fragment=None):
diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py
index 45cc68e47c..0c0f9ba6a8 100644
--- a/shopfloor/tests/test_menu.py
+++ b/shopfloor/tests/test_menu.py
@@ -12,11 +12,7 @@ def setUp(self):
with self.work_on_services(profile=self.profile) as work:
self.service = work.component(usage="menu")
- def test_menu_search(self):
- """Request /menu/search"""
- # Simulate the client searching menus
- response = self.service.dispatch("search")
- menus = self.env["shopfloor.menu"].search([])
+ def _assert_menu_response(self, response, menus):
self.assert_response(
response,
data={
@@ -36,6 +32,13 @@ def test_menu_search(self):
},
)
+ def test_menu_search(self):
+ """Request /menu/search"""
+ # Simulate the client searching menus
+ response = self.service.dispatch("search")
+ menus = self.env["shopfloor.menu"].search([])
+ self._assert_menu_response(response, menus)
+
def test_menu_search_restricted(self):
"""Request /menu/search with profile attributions"""
# Simulate the client searching menus
@@ -48,21 +51,20 @@ def test_menu_search_restricted(self):
response = self.service.dispatch("search")
my_menus = menus - menus_without_profile
- self.assert_response(
- response,
- data={
- "size": len(my_menus),
- "records": [
- {
- "id": menu.id,
- "name": menu.name,
- "scenario": menu.scenario,
- "picking_types": [
- {"id": picking_type.id, "name": picking_type.name}
- for picking_type in menu.picking_type_ids
- ],
- }
- for menu in my_menus
- ],
- },
- )
+ self._assert_menu_response(response, my_menus)
+
+ def test_menu_search_warehouse_filter(self):
+ """Request /menu/search with different warehouse on profile"""
+ menus = self.env["shopfloor.menu"].search([])
+ # should not be visible as the profile has another wh
+ menu_different_wh = menus[0]
+ other_wh = self.env["stock.warehouse"].create({"name": "Test", "code": "test"})
+ menu_different_wh.picking_type_ids.warehouse_id = other_wh
+
+ # should be visible to any profile
+ menu_no_wh = menus[1]
+ menu_no_wh.picking_type_ids.warehouse_id = False
+
+ response = self.service.dispatch("search")
+
+ self._assert_menu_response(response, menus - menu_different_wh)
From 932c0d4c7ccf41709e79409b1204d85550c2d853 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 6 May 2020 12:29:37 +0200
Subject: [PATCH 195/940] backend: add info fields for pickings
---
shopfloor/models/__init__.py | 1 +
shopfloor/models/stock_picking.py | 32 +++++++++++++++++++
shopfloor/models/stock_picking_batch.py | 41 ++++++++++++++++++-------
3 files changed, 63 insertions(+), 11 deletions(-)
create mode 100644 shopfloor/models/stock_picking.py
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index d86b15fec5..46d5a1ccf6 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -3,5 +3,6 @@
from . import shopfloor_profile
from . import stock_location
from . import stock_move_line
+from . import stock_picking
from . import stock_picking_batch
from . import stock_quant_package
diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py
new file mode 100644
index 0000000000..d2a7e60042
--- /dev/null
+++ b/shopfloor/models/stock_picking.py
@@ -0,0 +1,32 @@
+from odoo import api, fields, models
+
+
+class StockPicking(models.Model):
+ _inherit = "stock.picking"
+
+ total_weight = fields.Float(
+ compute="_compute_picking_info",
+ help="Technical field. Indicates total weight of pickings included.",
+ )
+ move_line_count = fields.Integer(
+ compute="_compute_picking_info",
+ help="Technical field. Indicates number of move lines included.",
+ )
+
+ @api.depends(
+ "move_line_ids", "move_line_ids.product_qty", "move_line_ids.product_id.weight"
+ )
+ def _compute_picking_info(self):
+ for item in self:
+ item.update(
+ {
+ "total_weight": item._calc_weight(),
+ "move_line_count": len(item.move_line_ids),
+ }
+ )
+
+ def _calc_weight(self):
+ weight = 0.0
+ for move_line in self.mapped("move_line_ids"):
+ weight += move_line.product_qty * move_line.product_id.weight
+ return weight
diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py
index c3f3173b03..8a34d26c09 100644
--- a/shopfloor/models/stock_picking_batch.py
+++ b/shopfloor/models/stock_picking_batch.py
@@ -1,4 +1,4 @@
-from odoo import fields, models
+from odoo import api, fields, models
class StockPickingBatch(models.Model):
@@ -10,15 +10,34 @@ class StockPickingBatch(models.Model):
help="Technical field. Indicates if a batch is destination is"
" asked once for all lines or for every line.",
)
+ picking_count = fields.Integer(
+ compute="_compute_picking_info",
+ help="Technical field. Indicates number of pickings included.",
+ )
+ move_line_count = fields.Integer(
+ compute="_compute_picking_info",
+ help="Technical field. Indicates number of move lines included.",
+ )
+ total_weight = fields.Float(
+ compute="_compute_picking_info",
+ help="Technical field. Indicates total weight of pickings included.",
+ )
- def total_weight(self):
- return self.calc_weight(self.picking_ids)
-
- def picking_weight(self, picking):
- return self.calc_weight(picking)
+ @api.depends("picking_ids.total_weight", "picking_ids.move_line_ids")
+ def _compute_picking_info(self):
+ for item in self:
+ assigned_pickings = item.picking_ids.filtered(
+ lambda picking: picking.state == "assigned"
+ )
+ item.update(
+ {
+ "picking_count": len(assigned_pickings.ids),
+ "move_line_count": len(
+ assigned_pickings.mapped("move_line_ids").ids
+ ),
+ "total_weight": item._calc_weight(assigned_pickings),
+ }
+ )
- def calc_weight(self, pickings):
- weight = 0.0
- for move_line in pickings.mapped("move_line_ids"):
- weight += move_line.product_qty * move_line.product_id.weight
- return weight
+ def _calc_weight(self, pickings):
+ return sum(pickings.mapped("total_weight"))
From d248d1e2111edc76b0bfc4b01f42b723f6a9bc41 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 6 May 2020 12:33:36 +0200
Subject: [PATCH 196/940] backend: get rid of obsolete picking_batch
* picking_batch was used only by cluster_picking
* introduce base_jsonify for data
---
shopfloor/__manifest__.py | 1 +
shopfloor/actions/data.py | 13 ++++
shopfloor/services/__init__.py | 3 -
shopfloor/services/cluster_picking.py | 78 +++++++++++++++----
shopfloor/services/schema.py | 9 +++
shopfloor/tests/__init__.py | 2 +-
...batch.py => test_cluster_picking_batch.py} | 62 +++++++--------
7 files changed, 116 insertions(+), 52 deletions(-)
rename shopfloor/tests/{test_picking_batch.py => test_cluster_picking_batch.py} (68%)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index bfb02438c8..3e58a73793 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -15,6 +15,7 @@
"stock",
"stock_picking_batch",
"base_rest",
+ "base_jsonify",
"auth_api_key",
# https://github.com/OCA/stock-logistics-warehouse/pull/808
"stock_picking_completion_info",
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 9cab2186c6..79335a1636 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -78,3 +78,16 @@ def move_line(self, move_line):
"location_src": self.location(move_line.location_id),
"location_dest": self.location(move_line.location_dest_id),
}
+
+ def _jsonify(self, recordset, parser):
+ res = recordset.jsonify(parser)
+ if len(recordset.ids) == 1:
+ return res[0]
+ return res
+
+ def picking_batch(self, record):
+ return self._jsonify(record, self._picking_batch_parser)
+
+ @property
+ def _picking_batch_parser(self):
+ return ["id", "name", "picking_count", "move_line_count", "total_weight:weight"]
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 8908b1cb7c..18e7b56db6 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -5,15 +5,12 @@
# generic services
from . import app
-from . import location
from . import menu
-from . import pack
from . import profile
# process services
from . import checkout
from . import cluster_picking
from . import delivery
-from . import picking_batch
from . import single_pack_putaway
from . import single_pack_transfer
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 842e08d414..f75e962e9e 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1,4 +1,5 @@
from odoo import _, fields
+from odoo.osv import expression
from odoo.tools.float_utils import float_compare
from odoo.addons.base_rest.components.service import to_bool, to_int
@@ -88,8 +89,7 @@ def find_batch(self):
* confirm_start: when it could find a batch
* start: when no batch is available
"""
- batch_service = self.component(usage="picking_batch")
- batches = batch_service._search()
+ batches = self._batch_picking_search()
selected = self._select_a_picking_batch(batches)
if selected:
return self._response_for_confirm_start(selected)
@@ -105,9 +105,48 @@ def list_batch(self):
Transitions:
* manual_selection: to the selection screen
"""
- batch_service = self.component(usage="picking_batch")
- batches = batch_service.search()["data"]
- return self._response(next_state="manual_selection", data=batches)
+ data_struct = self.actions_for("data")
+ batches = self._batch_picking_search()
+ data = {"records": data_struct.picking_batch(batches), "size": len(batches)}
+ return self._response(next_state="manual_selection", data=data)
+
+ def _batch_picking_base_search_domain(self):
+ return [
+ "|",
+ "&",
+ ("user_id", "=", False),
+ ("state", "=", "draft"),
+ "&",
+ ("user_id", "=", self.env.user.id),
+ ("state", "in", ("draft", "in_progress")),
+ ]
+
+ def _batch_picking_search(self, name_fragment=None, batch_ids=None):
+ domain = self._batch_picking_base_search_domain()
+ if name_fragment:
+ domain = expression.AND([domain, [("name", "ilike", name_fragment)]])
+ if batch_ids:
+ domain = expression.AND([domain, [("id", "in", batch_ids)]])
+ records = self.env["stock.picking.batch"].search(domain, order="id asc")
+ records = records.filtered(
+ # Include done/cancel because we want to be able to work on the
+ # batch even if some pickings are done/canceled. They'll should be
+ # ignored later.
+ lambda batch: all(
+ (
+ # When the batch is already in progress, we do not care
+ # about state of the pickings, because we want to be able
+ # to recover it in any case, even if, for instance, a stock
+ # error changed a picking to unavailable after the user
+ # started to work on the batch.
+ batch.state == "in_progress"
+ or picking.state in ("assigned", "done", "cancel")
+ )
+ and picking.picking_type_id == self.picking_type
+ for picking in batch.picking_ids
+ )
+ )
+ return records
# TODO this may be used in other scenarios? if so, extract
def _select_a_picking_batch(self, batches):
@@ -142,14 +181,14 @@ def _response_for_no_batch_found(self):
def _response_for_confirm_start(self, batch):
pickings = []
+ # TODO: use data.picking_batch
for picking in batch.picking_ids:
- p_weight = batch.picking_weight(picking)
p_values = {
"id": picking.id,
"name": picking.name,
"partner": None,
"move_line_count": len(picking.move_line_ids),
- "weight": p_weight,
+ "weight": picking.total_weight,
"origin": picking.origin or "",
}
if picking.partner_id:
@@ -160,7 +199,12 @@ def _response_for_confirm_start(self, batch):
pickings.append(p_values)
return self._response(
next_state="confirm_start",
- data={"id": batch.id, "name": batch.name, "pickings": pickings},
+ data={
+ "id": batch.id,
+ "name": batch.name,
+ "picking_count": batch.picking_count,
+ "pickings": pickings,
+ },
)
def _response_for_batch_cannot_be_selected(self):
@@ -189,9 +233,8 @@ def select(self, picking_batch_id):
concurrently for instance)
* confirm_start: after the batch has been assigned to the user
"""
- batch_service = self.component(usage="picking_batch")
- batch = batch_service._search(batch_ids=[picking_batch_id])
- selected = self._select_a_picking_batch(batch)
+ batches = self._batch_picking_search(batch_ids=[picking_batch_id])
+ selected = self._select_a_picking_batch(batches)
if selected:
return self._response_for_confirm_start(selected)
else:
@@ -271,8 +314,7 @@ def _data_move_line(self, line):
lot = line.lot_id
package = line.package_id
data = {
- # TODO have common methods to return general info
- # for each model
+ # TODO use `data` component
"id": line.id,
"quantity": line.product_uom_qty,
"postponed": line.shopfloor_postponed,
@@ -1522,5 +1564,11 @@ def _schema_for_completion_info(self):
@property
def _schema_for_batch_selection(self):
- batch_validator = self.component(usage="picking_batch.validator.response")
- return batch_validator.search()["data"]["schema"]
+ return {
+ "size": {"required": True, "type": "integer"},
+ "records": {
+ "type": "list",
+ "required": True,
+ "schema": {"type": "dict", "schema": self.schemas().picking_batch()},
+ },
+ }
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index e4762d247b..6ed975b0b5 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -102,3 +102,12 @@ def packaging(self):
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
}
+
+ def picking_batch(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "picking_count": {"required": True, "type": "integer"},
+ "move_line_count": {"required": True, "type": "integer"},
+ "weight": {"required": True, "nullable": True, "type": "float"},
+ }
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index baa5b40e96..7994d4bf51 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -3,10 +3,10 @@
from . import test_openapi
from . import test_profile
from . import test_actions_data
-from . import test_picking_batch
from . import test_single_pack_putaway
from . import test_single_pack_transfer
from . import test_cluster_picking_base
+from . import test_cluster_picking_batch
from . import test_cluster_picking_select
from . import test_cluster_picking_scan
from . import test_cluster_picking_skip
diff --git a/shopfloor/tests/test_picking_batch.py b/shopfloor/tests/test_cluster_picking_batch.py
similarity index 68%
rename from shopfloor/tests/test_picking_batch.py
rename to shopfloor/tests/test_cluster_picking_batch.py
index 0eb20edc82..114cd5354a 100644
--- a/shopfloor/tests/test_picking_batch.py
+++ b/shopfloor/tests/test_cluster_picking_batch.py
@@ -1,7 +1,7 @@
from .common import CommonCase, PickingBatchMixin
-class BatchPickingCase(CommonCase, PickingBatchMixin):
+class ClusterPickingBatchCase(CommonCase, PickingBatchMixin):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
@@ -41,14 +41,13 @@ def setUpClass(cls, *args, **kwargs):
def setUp(self):
super().setUp()
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
- self.service = work.component(usage="picking_batch")
+ self.service = work.component(usage="cluster_picking")
def test_search_empty(self):
"""No batch is available"""
# Simulate the client asking the list of picking batch
- response = self.service.dispatch("search")
# none of the pickings are assigned, so we can't work on them
- self.assert_response(response, data={"size": 0, "records": []})
+ self.assertFalse(self.service._batch_picking_search())
def test_search(self):
"""Return only draft batches with assigned pickings """
@@ -68,33 +67,30 @@ def test_search(self):
self.batch3.confirm_picking()
# Simulate the client asking the list of picking batch
- response = self.service.dispatch("search")
- self.assert_response(
- response,
- data={
- "size": 3,
- "records": [
- {
- "id": self.batch1.id,
- "name": self.batch1.name,
- "picking_count": 1,
- "move_line_count": 1,
- "weight": 0.0,
- },
- {
- "id": self.batch2.id,
- "name": self.batch2.name,
- "picking_count": 1,
- "move_line_count": 1,
- "weight": 0.0,
- },
- {
- "id": self.batch3.id,
- "name": self.batch3.name,
- "picking_count": 1,
- "move_line_count": 1,
- "weight": 0.0,
- },
- ],
- },
+ res = self.service._batch_picking_search()
+ self.assertRecordValues(
+ res,
+ [
+ {
+ "id": self.batch1.id,
+ "name": self.batch1.name,
+ "picking_count": 1,
+ "move_line_count": 1,
+ "total_weight": 0.0,
+ },
+ {
+ "id": self.batch2.id,
+ "name": self.batch2.name,
+ "picking_count": 1,
+ "move_line_count": 1,
+ "total_weight": 0.0,
+ },
+ {
+ "id": self.batch3.id,
+ "name": self.batch3.name,
+ "picking_count": 1,
+ "move_line_count": 1,
+ "total_weight": 0.0,
+ },
+ ],
)
From fa06fe5d5b4b4bf51688fd0c0722dd7d72e80892 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 6 May 2020 16:12:05 +0200
Subject: [PATCH 197/940] backend: REF data action w/ jsonify
* use jsonify
* provide multiple + single converter
* use it all around (except pack transfer scenario as it needs
refactoring)
---
shopfloor/actions/data.py | 195 ++++++++++++------
shopfloor/services/checkout.py | 34 +--
shopfloor/services/cluster_picking.py | 147 ++++---------
shopfloor/services/location.py | 101 ---------
shopfloor/services/pack.py | 60 ------
shopfloor/services/schema.py | 13 +-
shopfloor/tests/test_actions_data.py | 48 +++--
shopfloor/tests/test_checkout_base.py | 2 +-
shopfloor/tests/test_checkout_list_package.py | 7 +-
shopfloor/tests/test_cluster_picking_base.py | 8 +-
.../tests/test_cluster_picking_select.py | 4 +-
11 files changed, 234 insertions(+), 385 deletions(-)
delete mode 100644 shopfloor/services/location.py
delete mode 100644 shopfloor/services/pack.py
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 79335a1636..e71c9a5db9 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -12,81 +12,142 @@ class DataAction(Component):
_inherit = "shopfloor.process.action"
_usage = "data"
- def picking_summary(self, picking):
- return {
- "id": picking.id,
- "name": picking.name,
- "origin": picking.origin or "",
- "note": picking.note or "",
- "line_count": len(picking.move_line_ids),
- "partner": {"id": picking.partner_id.id, "name": picking.partner_id.name}
- if picking.partner_id
- else None,
- }
-
- def package(self, package, picking=None):
+ def _jsonify(self, recordset, parser, multi=False, **kw):
+ res = recordset.jsonify(parser)
+ if not multi:
+ return res[0] if res else None
+ return res
+
+ def partner(self, record, **kw):
+ return self._jsonify(record, self._partner_parser, **kw)
+
+ def partners(self, record, **kw):
+ return self.partner(record, multi=True)
+
+ @property
+ def _partner_parser(self):
+ return ["id", "name"]
+
+ def picking(self, record, **kw):
+ return self._jsonify(record, self._picking_parser, **kw)
+
+ def pickings(self, record, **kw):
+ return self.picking(record, multi=True)
+
+ @property
+ def _picking_parser(self):
+ return [
+ "id",
+ "name",
+ "origin",
+ "note",
+ ("partner_id:partner", self._partner_parser),
+ "move_line_count",
+ "total_weight:weight",
+ ]
+
+ def package(self, record, picking=None, **kw):
"""Return data for a stock.quant.package
If a picking is given, it will include the number of lines of the package
for the picking.
"""
- line_count = (
- len(picking.move_line_ids.filtered(lambda l: l.package_id == package))
- if picking
- else 0
- )
- return {
- "id": package.id,
- "name": package.name,
- # TODO
- "weight": 0,
- "line_count": line_count,
- "packaging_name": package.product_packaging_id.name or "",
- }
-
- def packaging(self, packaging):
- return {"id": packaging.id, "name": packaging.name}
-
- def lot(self, lot):
- return {"id": lot.id, "name": lot.name}
-
- def location(self, location):
- return {"id": location.id, "name": location.name}
-
- def move_line(self, move_line):
- return {
- "id": move_line.id,
- "qty_done": move_line.qty_done,
- "quantity": move_line.product_uom_qty,
- "product": {
- "id": move_line.product_id.id,
- "name": move_line.product_id.name,
- "display_name": move_line.product_id.display_name,
- "default_code": move_line.product_id.default_code or "",
- },
- "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name}
- if move_line.lot_id
- else None,
- "package_src": self.package(move_line.package_id, move_line.picking_id)
- if move_line.package_id
- else None,
- "package_dest": self.package(
- move_line.result_package_id, move_line.picking_id
+ data = self._jsonify(record, self._package_parser, **kw)
+ # handle special cases
+ if data and picking:
+ # TODO: exclude canceled and done?
+ lines = picking.move_line_ids.filtered(lambda l: l.package_id == record)
+ data.update({"move_line_count": len(lines)})
+ return data
+
+ def packages(self, records, picking=None, **kw):
+ return [self.package(rec, picking=picking) for rec in records]
+
+ @property
+ def _package_parser(self):
+ return [
+ "id",
+ "name",
+ "pack_weight:weight",
+ ("product_packaging_id:packaging", self._packaging_parser),
+ ]
+
+ def packaging(self, record, **kw):
+ return self._jsonify(record, self._packaging_parser, **kw)
+
+ def packagings(self, record, **kw):
+ return self.packaging(record, multi=True)
+
+ @property
+ def _packaging_parser(self):
+ return ["id", "name"]
+
+ def lot(self, record, **kw):
+ return self._jsonify(record, self._lot_parser, **kw)
+
+ def lots(self, record, **kw):
+ return self.lot(record, multi=True)
+
+ @property
+ def _lot_parser(self):
+ return ["id", "name", "ref"]
+
+ def location(self, record, **kw):
+ return self._jsonify(record, self._location_parser, **kw)
+
+ def locations(self, record, **kw):
+ return self.location(record, multi=True)
+
+ @property
+ def _location_parser(self):
+ return ["id", "name"]
+
+ def move_line(self, record, **kw):
+ data = self._jsonify(record, self._move_line_parser)
+ if data:
+ data.update(
+ {
+ "package_src": self.package(record.package_id, record.picking_id),
+ "package_dest": self.package(
+ record.result_package_id, record.picking_id,
+ ),
+ }
)
- if move_line.result_package_id
- else None,
- "location_src": self.location(move_line.location_id),
- "location_dest": self.location(move_line.location_dest_id),
- }
+ return data
- def _jsonify(self, recordset, parser):
- res = recordset.jsonify(parser)
- if len(recordset.ids) == 1:
- return res[0]
- return res
+ def move_lines(self, records, **kw):
+ return [self.move_line(rec) for rec in records]
+
+ @property
+ def _move_line_parser(self):
+ return [
+ "id",
+ "qty_done",
+ "product_uom_qty:quantity",
+ ("product_id:product", self._product_parser),
+ ("lot_id:lot", self._lot_parser),
+ ("location_id:location_src", self._location_parser),
+ ("location_dest_id:location_dest", self._location_parser),
+ ]
+
+ def product(self, record, **kw):
+ return self._jsonify(record, self._product_parser, **kw)
+
+ def products(self, record, **kw):
+ return self.product(record, multi=True)
+
+ @property
+ def _product_parser(self):
+ return ["id", "name", "display_name", "default_code"]
+
+ def picking_batch(self, record, with_pickings=True, **kw):
+ parser = self._picking_batch_parser
+ if with_pickings:
+ parser.append(("picking_ids:pickings", self._picking_parser))
+ return self._jsonify(record, parser, **kw)
- def picking_batch(self, record):
- return self._jsonify(record, self._picking_batch_parser)
+ def picking_batches(self, record, **kw):
+ return self.picking_batch(record, multi=True)
@property
def _picking_batch_parser(self):
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 27ca05f3ab..ba3c0f4225 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -154,11 +154,9 @@ def _response_for_select_document(self, message=None):
def _data_for_stock_picking(self, picking, done=False):
data_struct = self.actions_for("data")
- data = data_struct.picking_summary(picking)
+ data = data_struct.picking(picking)
line_picker = self._lines_checkout_done if done else self._lines_to_pack
- data.update(
- {"move_lines": [data_struct.move_line(ml) for ml in line_picker(picking)]}
- )
+ data.update({"move_lines": data_struct.move_lines(line_picker(picking))})
return data
def _lines_checkout_done(self, picking):
@@ -193,9 +191,7 @@ def _response_for_manual_selection(self, message=None):
order=self._order_for_list_stock_picking(),
)
data_struct = self.actions_for("data")
- data = {
- "pickings": [data_struct.picking_summary(picking) for picking in pickings]
- }
+ data = {"pickings": data_struct.pickings(pickings)}
return self._response(next_state="manual_selection", data=data, message=message)
def select(self, picking_id):
@@ -225,10 +221,8 @@ def _response_for_select_package(self, lines, message=None):
return self._response(
next_state="select_package",
data={
- "selected_move_lines": [
- data_struct.move_line(line) for line in lines.sorted()
- ],
- "picking": data_struct.picking_summary(picking),
+ "selected_move_lines": data_struct.move_lines(lines.sorted()),
+ "picking": data_struct.picking(picking),
},
message=message,
)
@@ -743,20 +737,15 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None):
},
)
data_struct = self.actions_for("data")
- picking_data = data_struct.picking_summary(picking)
- packages_data = [
- data_struct.package(package, picking=picking)
- for package in packages.sorted()
- ]
+ picking_data = data_struct.picking(picking)
+ packages_data = data_struct.packages(packages.sorted(), picking=picking)
data_struct = self.actions_for("data")
return self._response(
next_state="select_dest_package",
data={
"picking": picking_data,
"packages": packages_data,
- "selected_move_lines": [
- data_struct.move_line(line) for line in move_lines.sorted()
- ],
+ "selected_move_lines": data_struct.move_lines(move_lines.sorted()),
},
message=message,
)
@@ -864,12 +853,9 @@ def _response_for_change_packaging(self, picking, package, packaging_list):
return self._response(
next_state="change_packaging",
data={
- "picking": data_struct.picking_summary(picking),
+ "picking": data_struct.picking(picking),
"package": data_struct.package(package, picking=picking),
- "packagings": [
- data_struct.packaging(packaging)
- for packaging in packaging_list.sorted()
- ],
+ "packagings": data_struct.packagings(packaging_list.sorted()),
},
)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index f75e962e9e..1c5b767f16 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -71,6 +71,10 @@ class ClusterPicking(Component):
_usage = "cluster_picking"
_description = __doc__
+ @property
+ def data_struct(self):
+ return self.actions_for("data")
+
def find_batch(self):
"""Find a picking batch to work on and start it
@@ -105,9 +109,11 @@ def list_batch(self):
Transitions:
* manual_selection: to the selection screen
"""
- data_struct = self.actions_for("data")
batches = self._batch_picking_search()
- data = {"records": data_struct.picking_batch(batches), "size": len(batches)}
+ data = {
+ "records": self.data_struct.picking_batches(batches),
+ "size": len(batches),
+ }
return self._response(next_state="manual_selection", data=data)
def _batch_picking_base_search_domain(self):
@@ -142,7 +148,7 @@ def _batch_picking_search(self, name_fragment=None, batch_ids=None):
batch.state == "in_progress"
or picking.state in ("assigned", "done", "cancel")
)
- and picking.picking_type_id == self.picking_type
+ and picking.picking_type_id in self.picking_types
for picking in batch.picking_ids
)
)
@@ -180,31 +186,9 @@ def _response_for_no_batch_found(self):
)
def _response_for_confirm_start(self, batch):
- pickings = []
- # TODO: use data.picking_batch
- for picking in batch.picking_ids:
- p_values = {
- "id": picking.id,
- "name": picking.name,
- "partner": None,
- "move_line_count": len(picking.move_line_ids),
- "weight": picking.total_weight,
- "origin": picking.origin or "",
- }
- if picking.partner_id:
- p_values["partner"] = {
- "id": picking.partner_id.id,
- "name": picking.partner_id.name,
- }
- pickings.append(p_values)
return self._response(
next_state="confirm_start",
- data={
- "id": batch.id,
- "name": batch.name,
- "picking_count": batch.picking_count,
- "pickings": pickings,
- },
+ data=self.data_struct.picking_batch(batch, with_pickings=True),
)
def _response_for_batch_cannot_be_selected(self):
@@ -307,49 +291,16 @@ def _data_for_next_move_line(self, picking_batch):
return self._data_move_line(remaining_lines[0])
def _data_move_line(self, line):
- # TODO: use shopfloor.data.action.move_line
picking = line.picking_id
batch = picking.batch_id
product = line.product_id
- lot = line.lot_id
- package = line.package_id
- data = {
- # TODO use `data` component
- "id": line.id,
- "quantity": line.product_uom_qty,
- "postponed": line.shopfloor_postponed,
- "picking": {
- "id": picking.id,
- "name": picking.name,
- "origin": picking.origin or "",
- "note": picking.note or "",
- "partner": None,
- },
- "batch": {"id": batch.id, "name": batch.name},
- "product": {
- "id": product.id,
- "name": product.name,
- "display_name": product.display_name,
- "default_code": product.default_code or "",
- "qty_available": product.qty_available,
- },
- "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""}
- if lot
- else None,
- "location_src": {"id": line.location_id.id, "name": line.location_id.name},
- "location_dest": {
- "id": line.location_dest_id.id,
- "name": line.location_dest_id.name,
- },
- "pack": {"id": package.id, "name": package.name} if package else None,
- }
- if picking.partner_id:
- # TODO retrieve info always in the same way
- # maybe using base_jsonify
- data["picking"]["partner"] = {
- "id": picking.partner_id.id,
- "name": picking.partner_id.name,
- }
+ data = self.data_struct.move_line(line)
+ # additional values
+ data.pop("package_dest", None)
+ data["batch"] = self.data_struct.picking_batch(batch)
+ data["picking"] = self.data_struct.picking(picking)
+ data["postponed"] = line.shopfloor_postponed
+ data["product"]["qty_available"] = product.qty_available
return data
def unassign(self, picking_batch_id):
@@ -513,10 +464,9 @@ def _response_for_scan_destination(self, move_line, message=None):
last_picked_line = self._last_picked_line(move_line.picking_id)
if last_picked_line:
# suggest pack to be used for the next line
- data["destination_pack"] = {
- "id": last_picked_line.result_package_id.id,
- "name": last_picked_line.result_package_id.name,
- }
+ data["package_dest"] = self.data_struct.package(
+ last_picked_line.result_package_id
+ )
return self._response(next_state="scan_destination", data=data, message=message)
def scan_destination_pack(self, move_line_id, barcode, quantity):
@@ -630,16 +580,11 @@ def _planned_qty_in_location_is_empty(self, product, location):
return compare <= 0
def _response_for_zero_check(self, move_line):
- return self._response(
- next_state="zero_check",
- data={
- "id": move_line.id,
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- },
- )
+ data = {
+ "id": move_line.id,
+ "location_src": self.data_struct.location(move_line.location_id),
+ }
+ return self._response(next_state="zero_check", data=data)
def _are_all_dest_location_same(self, batch):
lines_to_unload = self._lines_to_unload(batch)
@@ -674,29 +619,25 @@ def _data_for_unload_all(self, batch):
# all the lines destinations are the same here, it looks
# only for the first one
first_line = fields.first(lines)
- return {
- "id": batch.id,
- "name": batch.name,
- "location_dest": {
- "id": first_line.location_dest_id.id,
- "name": first_line.location_dest_id.name,
- },
- }
+ data = self.data_struct.picking_batch(batch)
+ data.update(
+ {"location_dest": self.data_struct.location(first_line.location_dest_id)}
+ )
+ return data
def _data_for_unload_single(self, batch, package):
line = fields.first(
package.planned_move_line_ids.filtered(self._filter_for_unload)
)
- return {
- # TODO disambiguate "id" everywhere? (id -> picking_batch_id)
- "id": batch.id,
- "name": batch.name,
- "package": {"id": package.id, "name": package.name},
- "location_dest": {
- "id": line.location_dest_id.id,
- "name": line.location_dest_id.name,
- },
- }
+ # TODO disambiguate "id" everywhere? (id -> picking_batch_id)
+ data = self.data_struct.picking_batch(batch)
+ data.update(
+ {
+ "package": self.data_struct.package(package),
+ "location_dest": self.data_struct.location(line.location_dest_id),
+ }
+ )
+ return data
def _response_for_unload_all(self, batch, message=None):
return self._response(
@@ -1411,8 +1352,8 @@ def _schema_for_single_line_details(self):
"schema": {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
- "origin": {"type": "string", "nullable": False, "required": True},
- "note": {"type": "string", "nullable": False, "required": True},
+ "origin": {"type": "string", "nullable": True, "required": True},
+ "note": {"type": "string", "nullable": True, "required": True},
"partner": {
"type": "dict",
"required": False,
@@ -1464,7 +1405,7 @@ def _schema_for_single_line_details(self):
"schema": {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
- "ref": {"type": "string", "nullable": False, "required": True},
+ "ref": {"type": "string", "nullable": True, "required": True},
},
},
# TODO share parts of the schema?
@@ -1482,7 +1423,7 @@ def _schema_for_single_line_details(self):
"name": {"type": "string", "nullable": False, "required": True},
},
},
- "pack": {
+ "package_src": {
"type": "dict",
"required": False,
"nullable": True,
@@ -1491,7 +1432,7 @@ def _schema_for_single_line_details(self):
"name": {"type": "string", "nullable": False, "required": True},
},
},
- "destination_pack": {
+ "package_dest": {
"type": "dict",
"required": False,
"nullable": True,
diff --git a/shopfloor/services/location.py b/shopfloor/services/location.py
deleted file mode 100644
index 7bd237f4bd..0000000000
--- a/shopfloor/services/location.py
+++ /dev/null
@@ -1,101 +0,0 @@
-from odoo.osv import expression
-
-from odoo.addons.base_rest.components.service import to_int
-from odoo.addons.component.core import Component
-
-
-class ShopfloorLocation(Component):
- """Expose Stock Locations data for the current warehouse."""
-
- _inherit = "base.shopfloor.service"
- _name = "shopfloor.location"
- _usage = "location"
- _expose_model = "stock.location"
- _description = __doc__
-
- def search(self, name_fragment=None):
- """List available locations for current user"""
- domain = self._get_base_search_domain()
- if name_fragment:
- domain = expression.AND(
- [
- domain,
- [
- "|",
- ("name", "ilike", name_fragment),
- ("barcode", "ilike", name_fragment),
- ],
- ]
- )
- records = self.env[self._expose_model].search(domain)
- return self._response(
- data={"size": len(records), "records": self._to_json(records)}
- )
-
- def _get_base_search_domain(self):
- # TODO add filter on warehouse of the current profile
- return super()._get_base_search_domain()
-
- def _convert_one_record(self, record):
- return {
- "id": record.id,
- "name": record.name,
- "complete_name": record.complete_name,
- "barcode": record.barcode or "",
- }
-
-
-class ShopfloorLocationValidator(Component):
- """Validators for the Location endpoints"""
-
- _inherit = "base.shopfloor.validator"
- _name = "shopfloor.location.validator"
- _usage = "location.validator"
-
- def search(self):
- return {
- "name_fragment": {"type": "string", "nullable": True, "required": False}
- }
-
-
-class ShopfloorLocationValidatorResponse(Component):
- """Validators for the Location endpoints responses"""
-
- _inherit = "base.shopfloor.validator.response"
- _name = "shopfloor.location.validator.response"
- _usage = "location.validator.response"
-
- def search(self):
- return self._response_schema(
- {
- "size": {"coerce": to_int, "required": True, "type": "integer"},
- "records": {
- "type": "list",
- "schema": {
- "type": "dict",
- "schema": {
- "id": {
- "coerce": to_int,
- "required": True,
- "type": "integer",
- },
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- "complete_name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- "barcode": {
- "type": "string",
- "nullable": False,
- "required": False,
- },
- },
- },
- },
- }
- )
diff --git a/shopfloor/services/pack.py b/shopfloor/services/pack.py
deleted file mode 100644
index bf4d3ea2f3..0000000000
--- a/shopfloor/services/pack.py
+++ /dev/null
@@ -1,60 +0,0 @@
-from odoo.addons.base_rest.components.service import to_int
-from odoo.addons.component.core import Component
-
-
-class ShopfloorPack(Component):
- """Expose data about Stock Quant Packages"""
-
- _inherit = "base.shopfloor.service"
- _name = "shopfloor.pack"
- _usage = "pack"
- _description = __doc__
-
- def get_by_name(self, pack_name):
- """Get pack information"""
- search = self.actions_for("search")
- package = search.package_from_scan(pack_name)
- return self._response(data=self._to_json(package)[:1])
-
- def _convert_one_record(self, record):
- return {
- "id": record.id,
- "name": record.name,
- "location": {"id": record.location_id.id, "name": record.location_id.name},
- }
-
-
-class ShopfloorPackValidator(Component):
- """Validators for the Pack endpoints"""
-
- _inherit = "base.shopfloor.validator"
- _name = "shopfloor.pack.validator"
- _usage = "pack.validator"
-
- def get_by_name(self):
- return {"pack_name": {"type": "string", "nullable": False, "required": True}}
-
-
-class ShopfloorPackValidatorResponse(Component):
- """Validators for the Pack endpoints responses"""
-
- _inherit = "base.shopfloor.validator.response"
- _name = "shopfloor.pack.validator.response"
- _usage = "pack.validator.response"
-
- @property
- def _record_schema(self):
- return {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "location": {
- "type": "dict",
- "schema": {
- "id": {"coerce": to_int, "required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- }
-
- def get_by_name(self):
- return self._response_schema(self._record_schema)
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 6ed975b0b5..2335aad540 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -20,7 +20,8 @@ def picking(self):
"name": {"type": "string", "nullable": False, "required": True},
"origin": {"type": "string", "nullable": True, "required": True},
"note": {"type": "string", "nullable": True, "required": True},
- "line_count": {"type": "integer", "nullable": True, "required": True},
+ "move_line_count": {"type": "integer", "nullable": True, "required": True},
+ "weight": {"required": True, "nullable": True, "type": "float"},
"partner": {
"type": "dict",
"nullable": True,
@@ -81,14 +82,20 @@ def package(self):
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"weight": {"required": True, "nullable": True, "type": "float"},
- "line_count": {"required": True, "nullable": True, "type": "integer"},
- "packaging_name": {"required": True, "nullable": True, "type": "string"},
+ "move_line_count": {"required": True, "nullable": True, "type": "integer"},
+ "packaging": {
+ "type": "dict",
+ "required": True,
+ "nullable": True,
+ "schema": self.packaging(),
+ },
}
def lot(self):
return {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
+ "ref": {"type": "string", "nullable": True, "required": False},
}
def location(self):
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index c8f2207b9e..8adf3ba9bb 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -68,11 +68,15 @@ def test_data_location(self):
def test_data_lot(self):
lot = self.env["stock.production.lot"].create(
- {"product_id": self.product_b.id, "company_id": self.env.company.id}
+ {
+ "product_id": self.product_b.id,
+ "company_id": self.env.company.id,
+ "ref": "#FOO",
+ }
)
data = self.data.lot(lot)
self.assert_schema(self.schema.lot(), data)
- expected = {"id": lot.id, "name": lot.name}
+ expected = {"id": lot.id, "name": lot.name, "ref": "#FOO"}
self.assertDictEqual(data, expected)
def test_data_package(self):
@@ -83,23 +87,23 @@ def test_data_package(self):
expected = {
"id": package.id,
"name": package.name,
- "line_count": 1,
- "packaging_name": self.packaging.name,
- # TODO
+ "move_line_count": 1,
+ "packaging": self.data.packaging(package.product_packaging_id),
"weight": 0,
}
self.assertDictEqual(data, expected)
- def test_data_picking_summary(self):
+ def test_data_picking(self):
self.picking.write({"origin": "created by test", "note": "read me"})
- data = self.data.picking_summary(self.picking)
+ data = self.data.picking(self.picking)
self.assert_schema(self.schema.picking(), data)
expected = {
"id": self.picking.id,
- "line_count": 4,
+ "move_line_count": 4,
"name": self.picking.name,
"note": "read me",
"origin": "created by test",
+ "weight": 110.0,
"partner": {"id": self.customer.id, "name": self.customer.name},
}
self.assertDictEqual(data, expected)
@@ -126,16 +130,16 @@ def test_data_move_line_package(self):
"package_src": {
"id": move_line.package_id.id,
"name": move_line.package_id.name,
- "line_count": 1,
- "packaging_name": "",
+ "move_line_count": 1,
+ "packaging": None,
# TODO
"weight": 0,
},
"package_dest": {
"id": result_package.id,
"name": result_package.name,
- "line_count": 0,
- "packaging_name": self.packaging.name,
+ "move_line_count": 0,
+ "packaging": self.data.packaging(self.packaging),
# TODO
"weight": 0,
},
@@ -164,7 +168,11 @@ def test_data_move_line_lot(self):
"display_name": "[B] Product B",
"default_code": "B",
},
- "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name},
+ "lot": {
+ "id": move_line.lot_id.id,
+ "name": move_line.lot_id.name,
+ "ref": None,
+ },
"package_src": None,
"package_dest": None,
"location_src": {
@@ -193,20 +201,24 @@ def test_data_move_line_package_lot(self):
"display_name": "[C] Product C",
"default_code": "C",
},
- "lot": {"id": move_line.lot_id.id, "name": move_line.lot_id.name},
+ "lot": {
+ "id": move_line.lot_id.id,
+ "name": move_line.lot_id.name,
+ "ref": None,
+ },
"package_src": {
"id": move_line.package_id.id,
"name": move_line.package_id.name,
- "line_count": 1,
- "packaging_name": "",
+ "move_line_count": 1,
+ "packaging": None,
# TODO
"weight": 0,
},
"package_dest": {
"id": move_line.result_package_id.id,
"name": move_line.result_package_id.name,
- "line_count": 1,
- "packaging_name": "",
+ "move_line_count": 1,
+ "packaging": None,
# TODO
"weight": 0,
},
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index 16a2123148..ce6a969d6b 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -21,7 +21,7 @@ def _stock_picking_data(self, picking, **kw):
# we test the methods that structure data in test_actions_data.py
def _picking_summary_data(self, picking):
- return self.service.actions_for("data").picking_summary(picking)
+ return self.service.actions_for("data").picking(picking)
def _move_line_data(self, move_line):
return self.service.actions_for("data").move_line(move_line)
diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py
index a17bcb9d91..78af3be96c 100644
--- a/shopfloor/tests/test_checkout_list_package.py
+++ b/shopfloor/tests/test_checkout_list_package.py
@@ -13,9 +13,10 @@ def _assert_response_select_dest_package(
"picking": {
"id": picking.id,
"name": picking.name,
- "note": "",
- "origin": "",
- "line_count": len(picking.move_line_ids),
+ "note": None,
+ "origin": None,
+ "weight": 110.0,
+ "move_line_count": len(picking.move_line_ids),
"partner": {"id": self.customer.id, "name": self.customer.name},
},
"packages": [
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index aaa3be424a..7d8333bbff 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -56,7 +56,7 @@ def _line_data(self, move_line, qty=None):
"picking": {
"id": picking.id,
"name": picking.name,
- "note": "",
+ "note": None,
"origin": picking.origin,
"partner": {"id": self.customer.id, "name": self.customer.name},
},
@@ -68,10 +68,12 @@ def _line_data(self, move_line, qty=None):
"name": move_line.product_id.name,
"qty_available": move_line.product_id.qty_available,
},
- "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or ""}
+ "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or None}
if lot
else None,
- "pack": {"id": package.id, "name": package.name} if package else None,
+ "package_src": {"id": package.id, "name": package.name}
+ if package
+ else None,
}
@classmethod
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index b844873408..00ef30dbb2 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -380,7 +380,7 @@ def test_confirm_start_ok(self):
"picking": {
"id": picking.id,
"name": picking.name,
- "note": "",
+ "note": None,
"origin": picking.origin,
"partner": {"id": self.customer.id, "name": self.customer.name},
},
@@ -393,7 +393,7 @@ def test_confirm_start_ok(self):
"qty_available": first_move_line.product_id.qty_available,
},
"lot": None,
- "pack": {"id": package.id, "name": package.name},
+ "package_src": {"id": package.id, "name": package.name},
},
next_state="start_line",
)
From 9c8d2bc17d9dd2426a65d4f4ac1e58f9883a8729 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 7 May 2020 09:09:17 +0200
Subject: [PATCH 198/940] Apply suggestions from code review
---
shopfloor/models/stock_picking.py | 2 +-
shopfloor/models/stock_picking_batch.py | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py
index d2a7e60042..d266f38cce 100644
--- a/shopfloor/models/stock_picking.py
+++ b/shopfloor/models/stock_picking.py
@@ -6,7 +6,7 @@ class StockPicking(models.Model):
total_weight = fields.Float(
compute="_compute_picking_info",
- help="Technical field. Indicates total weight of pickings included.",
+ help="Technical field. Indicates total weight of transfers included.",
)
move_line_count = fields.Integer(
compute="_compute_picking_info",
diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py
index 8a34d26c09..e1f2093b60 100644
--- a/shopfloor/models/stock_picking_batch.py
+++ b/shopfloor/models/stock_picking_batch.py
@@ -12,7 +12,7 @@ class StockPickingBatch(models.Model):
)
picking_count = fields.Integer(
compute="_compute_picking_info",
- help="Technical field. Indicates number of pickings included.",
+ help="Technical field. Indicates number of transfers included.",
)
move_line_count = fields.Integer(
compute="_compute_picking_info",
@@ -20,7 +20,7 @@ class StockPickingBatch(models.Model):
)
total_weight = fields.Float(
compute="_compute_picking_info",
- help="Technical field. Indicates total weight of pickings included.",
+ help="Technical field. Indicates total weight of transfers included.",
)
@api.depends("picking_ids.total_weight", "picking_ids.move_line_ids")
From c53b5891b57c5ae1d565eed9692297b2d15ce98f Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 8 May 2020 09:45:03 +0200
Subject: [PATCH 199/940] cluster picking: refactor using _response_for_
methods
---
shopfloor/actions/message.py | 6 +
shopfloor/services/cluster_picking.py | 285 ++++++++++++--------------
2 files changed, 142 insertions(+), 149 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index e035a3fdaa..f7c56f8552 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -206,3 +206,9 @@ def lot_multiple_packages_scan_package(self):
"This lot is part of multiple packages, please scan a package."
),
}
+
+ def batch_transfer_complete(self):
+ return {
+ "message_type": "success",
+ "message": _("Batch Transfer complete"),
+ }
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 1c5b767f16..c08d51abbf 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -75,6 +75,84 @@ class ClusterPicking(Component):
def data_struct(self):
return self.actions_for("data")
+ @property
+ def msg_store(self):
+ return self.actions_for("message")
+
+ def _response_for_start(self, message=None):
+ return self._response(next_state="start", message=message,)
+
+ def _response_for_confirm_start(self, batch):
+ return self._response(
+ next_state="confirm_start",
+ data=self.data_struct.picking_batch(batch, with_pickings=True),
+ )
+
+ def _response_for_manual_selection(self, batches, message=None):
+ data = {
+ "records": self.data_struct.picking_batches(batches),
+ "size": len(batches),
+ }
+ return self._response(next_state="manual_selection", data=data, message=message)
+
+ def _response_for_start_line(self, move_line, message=None):
+ return self._response(
+ next_state="start_line",
+ data=self._data_move_line(move_line),
+ message=message,
+ )
+
+ def _response_for_scan_destination(self, move_line, message=None):
+ data = self._data_move_line(move_line)
+ last_picked_line = self._last_picked_line(move_line.picking_id)
+ if last_picked_line:
+ # suggest pack to be used for the next line
+ data["package_dest"] = self.data_struct.package(
+ last_picked_line.result_package_id
+ )
+ return self._response(next_state="scan_destination", data=data, message=message)
+
+ def _response_for_zero_check(self, move_line):
+ data = {
+ "id": move_line.id,
+ "location_src": self.data_struct.location(move_line.location_id),
+ }
+ return self._response(next_state="zero_check", data=data)
+
+ def _response_for_unload_all(self, batch, message=None):
+ return self._response(
+ next_state="unload_all",
+ data=self._data_for_unload_all(batch),
+ message=message,
+ )
+
+ def _response_for_confirm_unload_all(self, batch, message=None):
+ return self._response(
+ next_state="confirm_unload_all",
+ data=self._data_for_unload_all(batch),
+ message=message,
+ )
+
+ def _response_for_unload_single(self, batch, package, message=None):
+ return self._response(
+ next_state="unload_single",
+ data=self._data_for_unload_single(batch, package),
+ message=message,
+ )
+
+ def _response_for_unload_set_destination(self, batch, package, message=None):
+ return self._response(
+ next_state="unload_set_destination",
+ data=self._data_for_unload_single(batch, package),
+ message=message,
+ )
+
+ def _response_for_confirm_unload_set_destination(self, batch, package):
+ return self._response(
+ next_state="confirm_unload_set_destination",
+ data=self._data_for_unload_single(batch, package),
+ )
+
def find_batch(self):
"""Find a picking batch to work on and start it
@@ -98,7 +176,14 @@ def find_batch(self):
if selected:
return self._response_for_confirm_start(selected)
else:
- return self._response_for_no_batch_found()
+ return self._response_for_start(
+ message={
+ "message_type": "info",
+ "message": _(
+ "No more work to do, please create a new batch transfer"
+ ),
+ },
+ )
def list_batch(self):
"""List picking batch on which user can work
@@ -110,11 +195,7 @@ def list_batch(self):
* manual_selection: to the selection screen
"""
batches = self._batch_picking_search()
- data = {
- "records": self.data_struct.picking_batches(batches),
- "size": len(batches),
- }
- return self._response(next_state="manual_selection", data=data)
+ return self._response_for_manual_selection(batches)
def _batch_picking_base_search_domain(self):
return [
@@ -176,30 +257,6 @@ def _select_a_picking_batch(self, batches):
return batch
return self.env["stock.picking.batch"]
- def _response_for_no_batch_found(self):
- return self._response(
- next_state="start",
- message={
- "message_type": "info",
- "message": _("No more work to do, please create a new batch transfer"),
- },
- )
-
- def _response_for_confirm_start(self, batch):
- return self._response(
- next_state="confirm_start",
- data=self.data_struct.picking_batch(batch, with_pickings=True),
- )
-
- def _response_for_batch_cannot_be_selected(self):
- return self._response(
- base_response=self.list_batch(),
- message={
- "message_type": "warning",
- "message": _("This batch cannot be selected."),
- },
- )
-
def select(self, picking_batch_id):
"""Manually select a picking batch
@@ -222,7 +279,13 @@ def select(self, picking_batch_id):
if selected:
return self._response_for_confirm_start(selected)
else:
- return self._response_for_batch_cannot_be_selected()
+ return self._response(
+ base_response=self.list_batch(),
+ message={
+ "message_type": "warning",
+ "message": _("This batch cannot be selected."),
+ },
+ )
def confirm_start(self, picking_batch_id):
"""User confirms they start a batch
@@ -283,8 +346,7 @@ def _next_line_for_pick(self, picking_batch):
return fields.first(remaining_lines)
def _response_batch_does_not_exist(self):
- message = self.actions_for("message")
- return self._response(next_state="start", message=message.record_not_found())
+ return self._response_for_start(message=self.msg_store.record_not_found())
def _data_for_next_move_line(self, picking_batch):
remaining_lines = self._lines_to_pick(picking_batch)
@@ -312,7 +374,7 @@ def unassign(self, picking_batch_id):
batch = self.env["stock.picking.batch"].browse(picking_batch_id)
if batch.exists():
batch.write({"state": "draft", "user_id": False})
- return self._response(next_state="start")
+ return self._response_for_start()
def scan_line(self, move_line_id, barcode):
"""Scan a location, a pack, a product or a lots
@@ -337,11 +399,10 @@ def scan_line(self, move_line_id, barcode):
pack meanwhile (race condition).
* scan_destination: if the barcode matches.
"""
- message = self.actions_for("message")
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- return self._response(
- next_state="start", message=message.unrecoverable_error()
+ return self._response_for_start(
+ message=self.msg_store.unrecoverable_error()
)
search = self.actions_for("search")
@@ -366,17 +427,16 @@ def scan_line(self, move_line_id, barcode):
return self._scan_line_by_location(picking, move_line, location)
return self._response_for_start_line(
- move_line, message=message.barcode_not_found()
+ move_line, message=self.msg_store.barcode_not_found()
)
def _scan_line_by_package(self, picking, move_line, package):
return self._response_for_scan_destination(move_line)
def _scan_line_by_product(self, picking, move_line, product):
- message = self.actions_for("message")
if move_line.product_id.tracking in ("lot", "serial"):
return self._response_for_start_line(
- move_line, message=message.scan_lot_on_product_tracked_by_lot()
+ move_line, message=self.msg_store.scan_lot_on_product_tracked_by_lot()
)
# if we scanned a product and it's part of several packages, we can't be
@@ -391,12 +451,12 @@ def _scan_line_by_product(self, picking, move_line, product):
# package.
if packages and len({l.package_id for l in other_product_lines}) > 1:
return self._response_for_start_line(
- move_line, message=message.product_multiple_packages_scan_package()
+ move_line,
+ message=self.msg_store.product_multiple_packages_scan_package(),
)
return self._response_for_scan_destination(move_line)
def _scan_line_by_lot(self, picking, move_line, lot):
- message = self.actions_for("message")
# if we scanned a lot and it's part of several packages, we can't be
# sure the user scanned the correct one, in such case, ask to scan a package
other_lot_lines = picking.move_line_ids.filtered(lambda l: l.lot_id == lot)
@@ -407,12 +467,11 @@ def _scan_line_by_lot(self, picking, move_line, lot):
# package.
if packages and len({l.package_id for l in other_lot_lines}) > 1:
return self._response_for_start_line(
- move_line, message=message.lot_multiple_packages_scan_package()
+ move_line, message=self.msg_store.lot_multiple_packages_scan_package()
)
return self._response_for_scan_destination(move_line)
def _scan_line_by_location(self, picking, move_line, location):
- message = self.actions_for("message")
# When a user scan a location, we accept only when we knows that
# they scanned the good thing, so if in the location we have
# several lots (on a package or a product), several packages,
@@ -435,40 +494,27 @@ def _scan_line_by_location(self, picking, move_line, location):
if len(lots) > 1:
return self._response_for_start_line(
move_line,
- message=message.several_lots_in_location(move_line.location_id),
+ message=self.msg_store.several_lots_in_location(move_line.location_id),
)
if len(packages | products) > 1:
if move_line.package_id:
return self._response_for_start_line(
move_line,
- message=message.several_packs_in_location(move_line.location_id),
+ message=self.msg_store.several_packs_in_location(
+ move_line.location_id
+ ),
)
else:
return self._response_for_start_line(
move_line,
- message=message.several_products_in_location(move_line.location_id),
+ message=self.msg_store.several_products_in_location(
+ move_line.location_id
+ ),
)
return self._response_for_scan_destination(move_line)
- def _response_for_start_line(self, move_line, message=None):
- return self._response(
- next_state="start_line",
- data=self._data_move_line(move_line),
- message=message,
- )
-
- def _response_for_scan_destination(self, move_line, message=None):
- data = self._data_move_line(move_line)
- last_picked_line = self._last_picked_line(move_line.picking_id)
- if last_picked_line:
- # suggest pack to be used for the next line
- data["package_dest"] = self.data_struct.package(
- last_picked_line.result_package_id
- )
- return self._response(next_state="scan_destination", data=data, message=message)
-
def scan_destination_pack(self, move_line_id, barcode, quantity):
"""Scan the destination package (bin) for a move line
@@ -489,11 +535,10 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
have the same destination.
* start_line: to pick the next line if any.
"""
- message = self.actions_for("message")
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- return self._response(
- next_state="start", message=message.unrecoverable_error()
+ return self._response_for_start(
+ message=self.msg_store.unrecoverable_error()
)
rounding = move_line.product_uom_id.rounding
compare = float_compare(
@@ -521,11 +566,10 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
move_line.product_uom_qty = quantity
search = self.actions_for("search")
- message = self.actions_for("message")
bin_package = search.package_from_scan(barcode)
if not bin_package:
return self._response_for_scan_destination(
- move_line, message=message.bin_not_found_for_barcode(barcode)
+ move_line, message=self.msg_store.bin_not_found_for_barcode(barcode)
)
# the scanned package can contain only move lines of the same picking
@@ -553,7 +597,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
return self._pick_next_line(
batch,
- message=message.x_units_put_in_package(
+ message=self.msg_store.x_units_put_in_package(
move_line.qty_done, move_line.product_id, move_line.result_package_id
),
)
@@ -579,13 +623,6 @@ def _planned_qty_in_location_is_empty(self, product, location):
compare = float_compare(planned, 0, precision_rounding=rounding)
return compare <= 0
- def _response_for_zero_check(self, move_line):
- data = {
- "id": move_line.id,
- "location_src": self.data_struct.location(move_line.location_id),
- }
- return self._response(next_state="zero_check", data=data)
-
def _are_all_dest_location_same(self, batch):
lines_to_unload = self._lines_to_unload(batch)
return len(lines_to_unload.mapped("location_dest_id")) == 1
@@ -612,7 +649,7 @@ def prepare_unload(self, picking_batch_id):
else:
# the lines have different destinations
batch.cluster_picking_unload_all = False
- return self._response_for_unload_single(batch)
+ return self._unload_next_package(batch)
def _data_for_unload_all(self, batch):
lines = self._lines_to_unload(batch)
@@ -639,20 +676,6 @@ def _data_for_unload_single(self, batch, package):
)
return data
- def _response_for_unload_all(self, batch, message=None):
- return self._response(
- next_state="unload_all",
- data=self._data_for_unload_all(batch),
- message=message,
- )
-
- def _response_for_unload_all_need_confirm(self, batch, message=None):
- return self._response(
- next_state="confirm_unload_all",
- data=self._data_for_unload_all(batch),
- message=message,
- )
-
def _filter_for_unload(self, line):
return (
line.state == "assigned"
@@ -673,15 +696,6 @@ def _next_bin_package_for_unload_single(self, batch):
packages = self._bin_packages_to_unload(batch)
return fields.first(packages)
- def _response_for_unload_single(self, batch):
- next_package = self._next_bin_package_for_unload_single(batch)
- if not next_package:
- return self._unload_end(batch)
- return self._response(
- next_state="unload_single",
- data=self._data_for_unload_single(batch, next_package),
- )
-
def is_zero(self, move_line_id, zero):
"""Confirm or not if the source location of a move has zero qty
@@ -696,11 +710,10 @@ def is_zero(self, move_line_id, zero):
* unload_single: if all lines have a destination package and different
destination
"""
- message = self.actions_for("message")
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- return self._response(
- next_state="start", message=message.unrecoverable_error()
+ return self._response_for_start(
+ message=self.msg_store.unrecoverable_error()
)
if not zero:
@@ -714,7 +727,7 @@ def is_zero(self, move_line_id, zero):
batch = move_line.picking_id.batch_id
return self._pick_next_line(
batch,
- message=message.x_units_put_in_package(
+ message=self.msg_store.x_units_put_in_package(
move_line.qty_done, move_line.product_id, move_line.result_package_id
),
)
@@ -733,9 +746,8 @@ def skip_line(self, move_line_id):
"""
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- message = self.actions_for("message")
- return self._response(
- next_state="start", message=message.unrecoverable_error()
+ return self._response_for_start(
+ message=self.msg_store.unrecoverable_error()
)
# flag as postponed
move_line.shopfloor_postponed = True
@@ -812,8 +824,6 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
if not batch.exists():
return self._response_batch_does_not_exist()
- message = self.actions_for("message")
-
# In case /set_destination_all was called and the destinations were
# in fact no the same... restart the unloading step over
if not self._are_all_dest_location_same(batch):
@@ -828,18 +838,18 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
scanned_location = self.actions_for("search").location_from_scan(barcode)
if not scanned_location:
return self._response_for_unload_all(
- batch, message=message.no_location_found()
+ batch, message=self.msg_store.no_location_found()
)
if not scanned_location.is_sublocation_of(
picking_type.default_location_dest_id
):
return self._response_for_unload_all(
- batch, message=message.dest_location_not_allowed()
+ batch, message=self.msg_store.dest_location_not_allowed()
)
if not scanned_location.is_sublocation_of(first_line.location_dest_id):
if not confirmation:
- return self._response_for_unload_all_need_confirm(batch)
+ return self._response_for_confirm_unload_all(batch)
self._unload_set_destination_on_lines(lines, scanned_location)
return self._unload_end(batch)
@@ -861,7 +871,9 @@ def _unload_end(self, batch):
# do not use the 'done()' method because it does many things we
# don't care about
batch.state = "done"
- return self._response_batch_complete()
+ return self._response_for_start(
+ message=self.msg_store.batch_transfer_complete()
+ )
next_line = self._next_line_for_pick(batch)
if next_line:
@@ -872,16 +884,9 @@ def _unload_end(self, batch):
# produce backorders)
batch.mapped("picking_ids").action_done()
batch.state = "done"
- return self._response_batch_complete()
-
- def _response_batch_complete(self):
- return self._response(
- next_state="start",
- message={
- "message_type": "success",
- "message": _("Batch Transfer complete"),
- },
- )
+ return self._response_for_start(
+ message=self.msg_store.batch_transfer_complete()
+ )
def unload_split(self, picking_batch_id):
"""Indicates that now the batch must be treated line per line
@@ -903,7 +908,7 @@ def unload_split(self, picking_batch_id):
batch.cluster_picking_unload_all = False
- return self._response_for_unload_single(batch)
+ return self._unload_next_package(batch)
# TODO we shouldn't need this endpoint if we implement the "completion
# info" screen as a kind of generic info box instead of a state
@@ -937,26 +942,13 @@ def unload_scan_pack(self, picking_batch_id, package_id, barcode):
if not package.exists():
return self._unload_next_package(batch)
if package.name != barcode:
- return self._response(
- next_state="unload_single",
- data=self._data_for_unload_single(batch, package),
+ return self._response_for_unload_single(
+ batch,
+ package,
message={"message_type": "error", "message": _("Wrong bin")},
)
return self._response_for_unload_set_destination(batch, package)
- def _response_for_unload_set_destination(self, batch, package, message=None):
- return self._response(
- next_state="unload_set_destination",
- data=self._data_for_unload_single(batch, package),
- message=message,
- )
-
- def _response_for_confirm_unload_set_destination(self, batch, package):
- return self._response(
- next_state="confirm_unload_set_destination",
- data=self._data_for_unload_single(batch, package),
- )
-
def unload_scan_destination(
self, picking_batch_id, package_id, barcode, confirmation=False
):
@@ -979,8 +971,6 @@ def unload_scan_destination(
to handle the closing of the batch to create backorders.
"""
- message = self.actions_for("message")
-
batch = self.env["stock.picking.batch"].browse(picking_batch_id)
if not batch.exists():
return self._response_batch_does_not_exist()
@@ -1001,13 +991,13 @@ def unload_scan_destination(
scanned_location = self.actions_for("search").location_from_scan(barcode)
if not scanned_location:
return self._response_for_unload_set_destination(
- batch, package, message=message.no_location_found()
+ batch, package, message=self.msg_store.no_location_found()
)
if not scanned_location.is_sublocation_of(
picking_type.default_location_dest_id
):
return self._response_for_unload_set_destination(
- batch, package, message=message.dest_location_not_allowed()
+ batch, package, message=self.msg_store.dest_location_not_allowed()
)
if not scanned_location.is_sublocation_of(first_line.location_dest_id):
@@ -1021,10 +1011,7 @@ def unload_scan_destination(
def _unload_next_package(self, batch):
next_package = self._next_bin_package_for_unload_single(batch)
if next_package:
- return self._response(
- next_state="unload_single",
- data=self._data_for_unload_single(batch, next_package),
- )
+ return self._response_for_unload_single(batch, next_package)
return self._unload_end(batch)
From 63a9bb14096c1c35e33174ba883106fe49f90d12 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 9 Mar 2020 12:49:16 +0100
Subject: [PATCH 200/940] backend: add new 'popup' option in rest messages to
handle completion info
The 'completion info' screen is a screen which can be used in several
workflows and always has: a message about completion of pickings (the
next picking in the chain is ready), and an "OK" button. The OK button
goes to the next state (beginning of the workflow, next line, ...).
One option for this screen would be to have a state on the client and
transition like all the other states, but it means we have then to have
and endpoint called after the "OK" button to know what is the next
endpoint.
Since this screen is so simple - a message and an OK button - having
a reusable structure for this seems better. We cannot use the existing
"message" structure, because an endpoint can return a message + a popup
(for instance "X units put in location Y" + completion info popup).
The popup goes alongside the next state to reach, so it acts more as a
modal than as a screen.
---
shopfloor/actions/__init__.py | 2 +
shopfloor/actions/completion_info.py | 41 +++++++++++
shopfloor/models/stock_picking_batch.py | 6 --
shopfloor/services/cluster_picking.py | 72 ++++++++-----------
shopfloor/services/service.py | 10 ++-
shopfloor/services/single_pack_transfer.py | 28 ++++----
shopfloor/services/validator.py | 7 +-
shopfloor/tests/common.py | 7 +-
.../tests/test_cluster_picking_unload.py | 66 +++++++++++++----
shopfloor/tests/test_single_pack_transfer.py | 19 ++++-
10 files changed, 174 insertions(+), 84 deletions(-)
create mode 100644 shopfloor/actions/completion_info.py
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index b98bf56e07..a5971aad7c 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -18,6 +18,8 @@
"""
from . import base_action
from . import data
+from . import completion_info
+
from . import message
from . import pack_transfer_validate
from . import search
diff --git a/shopfloor/actions/completion_info.py b/shopfloor/actions/completion_info.py
new file mode 100644
index 0000000000..16cab4a0cf
--- /dev/null
+++ b/shopfloor/actions/completion_info.py
@@ -0,0 +1,41 @@
+from odoo import _
+
+from odoo.addons.component.core import Component
+
+
+class CompletionInfo(Component):
+ """Provide methods for completion info of pickings
+
+ They are based on the module "stock_picking_completion_info" from
+ OCA/stock-logistics-warehouse.
+ """
+
+ _name = "shopfloor.completion.info.action"
+ _inherit = "shopfloor.process.action"
+ _usage = "completion.info"
+
+ def popup(self, move_lines):
+ """Return a popup if move lines make chained pickings ready
+
+ Return None in case no popup should be displayed.
+ """
+ pickings = move_lines.mapped("picking_id").filtered(
+ lambda p: p.picking_type_id.display_completion_info
+ and p.completion_info == "next_picking_ready"
+ )
+ if not pickings:
+ return None
+ next_pickings = pickings.mapped("move_lines.move_dest_ids.picking_id").filtered(
+ lambda p: p.state == "assigned"
+ )
+ if not next_pickings:
+ return None
+ return {
+ "body": _(
+ "Last operation of transfer {}. "
+ "Next operation ({}) is ready to proceed."
+ ).format(
+ ", ".join(pickings.mapped("name")),
+ ", ".join(next_pickings.mapped("name")),
+ )
+ }
diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py
index e1f2093b60..aff3ef2ecf 100644
--- a/shopfloor/models/stock_picking_batch.py
+++ b/shopfloor/models/stock_picking_batch.py
@@ -4,12 +4,6 @@
class StockPickingBatch(models.Model):
_inherit = "stock.picking.batch"
- cluster_picking_unload_all = fields.Boolean(
- default=False,
- copy=False,
- help="Technical field. Indicates if a batch is destination is"
- " asked once for all lines or for every line.",
- )
picking_count = fields.Integer(
compute="_compute_picking_info",
help="Technical field. Indicates number of transfers included.",
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index c08d51abbf..58abeccaee 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -79,8 +79,8 @@ def data_struct(self):
def msg_store(self):
return self.actions_for("message")
- def _response_for_start(self, message=None):
- return self._response(next_state="start", message=message,)
+ def _response_for_start(self, message=None, popup=None):
+ return self._response(next_state="start", message=message, popup=popup)
def _response_for_confirm_start(self, batch):
return self._response(
@@ -95,11 +95,12 @@ def _response_for_manual_selection(self, batches, message=None):
}
return self._response(next_state="manual_selection", data=data, message=message)
- def _response_for_start_line(self, move_line, message=None):
+ def _response_for_start_line(self, move_line, message=None, popup=None):
return self._response(
next_state="start_line",
data=self._data_move_line(move_line),
message=message,
+ popup=popup,
)
def _response_for_scan_destination(self, move_line, message=None):
@@ -133,11 +134,12 @@ def _response_for_confirm_unload_all(self, batch, message=None):
message=message,
)
- def _response_for_unload_single(self, batch, package, message=None):
+ def _response_for_unload_single(self, batch, package, message=None, popup=None):
return self._response(
next_state="unload_single",
data=self._data_for_unload_single(batch, package),
message=message,
+ popup=popup,
)
def _response_for_unload_set_destination(self, batch, package, message=None):
@@ -631,24 +633,20 @@ def prepare_unload(self, picking_batch_id):
"""Initiate the unloading phase of the scenario
If the destination of all the move lines still to unload is the same,
- it sets the flag ``cluster_picking_unload_all`` to True on
- ``stock.batch.picking``.
Everytime this method is called, it resets the flag according to the
condition above.
Transitions:
- * unload_all: when ``cluster_picking_unload_all`` is True
- * unload_single: when ``cluster_picking_unload_all`` is False
+ * unload_all: when all lines go to the same destination
+ * unload_single: when lines have different destinations
"""
batch = self.env["stock.picking.batch"].browse(picking_batch_id)
if not batch.exists():
return self._response_batch_does_not_exist()
if self._are_all_dest_location_same(batch):
- batch.cluster_picking_unload_all = True
return self._response_for_unload_all(batch)
else:
# the lines have different destinations
- batch.cluster_picking_unload_all = False
return self._unload_next_package(batch)
def _data_for_unload_all(self, batch):
@@ -852,7 +850,9 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
return self._response_for_confirm_unload_all(batch)
self._unload_set_destination_on_lines(lines, scanned_location)
- return self._unload_end(batch)
+ completion_info = self.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(lines)
+ return self._unload_end(batch, completion_info_popup=completion_info_popup)
def _unload_set_destination_on_lines(self, lines, location):
lines.write({"shopfloor_unloaded": True, "location_dest_id": location.id})
@@ -866,18 +866,19 @@ def _unload_set_destination_on_lines(self, lines, location):
if all(l.shopfloor_unloaded for l in picking_lines):
picking.action_done()
- def _unload_end(self, batch):
+ def _unload_end(self, batch, completion_info_popup=None):
if all(picking.state == "done" for picking in batch.picking_ids):
# do not use the 'done()' method because it does many things we
# don't care about
batch.state = "done"
return self._response_for_start(
- message=self.msg_store.batch_transfer_complete()
+ message=self.msg_store.batch_transfer_complete(),
+ popup=completion_info_popup,
)
next_line = self._next_line_for_pick(batch)
if next_line:
- return self._response_for_start_line(next_line)
+ return self._response_for_start_line(next_line, popup=completion_info_popup)
else:
# TODO add tests for this (for instance a picking is not 'done'
# because a move was unassigned, we want to validate the batch to
@@ -885,7 +886,8 @@ def _unload_end(self, batch):
batch.mapped("picking_ids").action_done()
batch.state = "done"
return self._response_for_start(
- message=self.msg_store.batch_transfer_complete()
+ message=self.msg_store.batch_transfer_complete(),
+ popup=completion_info_popup,
)
def unload_split(self, picking_batch_id):
@@ -893,9 +895,6 @@ def unload_split(self, picking_batch_id):
Even if the move lines to unload all have the same destination.
- It sets the flag ``stock_picking_batch.cluster_picking_unload_all`` to
- False.
-
Note: if we go back to the first phase of picking and start a new
phase of unloading, the flag is reevaluated to the initial condition.
@@ -906,25 +905,8 @@ def unload_split(self, picking_batch_id):
if not batch.exists():
return self._response_batch_does_not_exist()
- batch.cluster_picking_unload_all = False
-
return self._unload_next_package(batch)
- # TODO we shouldn't need this endpoint if we implement the "completion
- # info" screen as a kind of generic info box instead of a state
- def unload_router(self, picking_batch_id):
- """Called after the info screen, route to the next state
-
- No side effect in Odoo.
-
- Transitions:
- * unload_single: if the batch still has packs to unload
- * start_line: if the batch still has lines to pick
- * start: if the batch is done. In this case, this method *has*
- to handle the closing of the batch to create backorders.
- """
- return self._response()
-
def unload_scan_pack(self, picking_batch_id, package_id, barcode):
"""Check that the operator scans the correct package (bin) on unload
@@ -963,9 +945,6 @@ def unload_scan_destination(
* confirm_unload_set_destination: the destination is valid but not the
expected, ask a confirmation. This state has to call again the
endpoint with confirmation=True
- * show_completion_info: the completion info of the picking is
- "next_picking_ready", it will show an info box to the user, the js
- client should then call /unload_router to know the next state
* start_line: if the batch still has lines to pick
* start: if the batch is done. In this case, this method *has*
to handle the closing of the batch to create backorders.
@@ -1006,13 +985,20 @@ def unload_scan_destination(
self._unload_set_destination_on_lines(lines, scanned_location)
- return self._unload_next_package(batch)
+ completion_info = self.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(lines)
- def _unload_next_package(self, batch):
+ return self._unload_next_package(
+ batch, completion_info_popup=completion_info_popup
+ )
+
+ def _unload_next_package(self, batch, completion_info_popup=None):
next_package = self._next_bin_package_for_unload_single(batch)
if next_package:
- return self._response_for_unload_single(batch, next_package)
- return self._unload_end(batch)
+ return self._response_for_unload_single(
+ batch, next_package, popup=completion_info_popup
+ )
+ return self._unload_end(batch, completion_info_popup=completion_info_popup)
class ShopfloorClusterPickingValidator(Component):
@@ -1137,7 +1123,6 @@ def _states(self):
"unload_single": self._schema_for_unload_single,
"unload_set_destination": self._schema_for_unload_single,
"confirm_unload_set_destination": self._schema_for_unload_single,
- "show_completion_info": self._schema_for_completion_info,
}
def find_batch(self):
@@ -1276,7 +1261,6 @@ def unload_scan_destination(self):
"unload_single",
"unload_set_destination",
"confirm_unload_set_destination",
- "show_completion_info",
"start",
"start_line",
}
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index ba4b867ddb..b1c8d4f512 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -78,7 +78,9 @@ def _get_output_validator(self, method_name):
)
return validator_component._get_validator(method_name)
- def _response(self, base_response=None, data=None, next_state=None, message=None):
+ def _response(
+ self, base_response=None, data=None, next_state=None, message=None, popup=None
+ ):
"""Base "envelope" for the responses
All the keys are optional.
@@ -92,6 +94,9 @@ def _response(self, base_response=None, data=None, next_state=None, message=None
application must reach
:param message: dictionary for the message to show in the client
application (see ``_response_schema`` for the keys)
+ :param popup: dictionary for a popup to show in the client application
+ (see ``_response_schema`` for the keys). The popup is displayed before
+ reaching the next state.
"""
if base_response:
response = base_response.copy()
@@ -118,6 +123,9 @@ def _response(self, base_response=None, data=None, next_state=None, message=None
if message:
response["message"] = message
+ if popup:
+ response["popup"] = popup
+
return response
def _get_openapi_default_parameters(self):
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 2deccf5fc9..a226995420 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -1,8 +1,6 @@
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
-# TODO add completion info screen
-
class SinglePackTransfer(Component):
"""Methods for the Single Pack Transfer Process"""
@@ -151,13 +149,12 @@ def _response_for_location_need_confirm(self, move_line, pack, to_location):
data=self._data_after_package_scanned(move_line, pack),
)
- def _response_for_validate_success(self, last=False):
+ def _response_for_validate_success(self, completion_info_popup=None):
message = self.actions_for("message")
- next_state = "start"
- if last:
- next_state = "show_completion_info"
return self._response(
- next_state=next_state, message=message.confirm_pack_moved()
+ next_state="start",
+ message=message.confirm_pack_moved(),
+ popup=completion_info_popup,
)
def validate(self, package_level_id, location_barcode, confirmation=False):
@@ -193,8 +190,13 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
)
pack_transfer.set_destination_and_done(move, scanned_location)
- last = move.picking_id.completion_info == "next_picking_ready"
- return self._response_for_validate_success(last=last)
+
+ completion_info = self.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(package.move_line_ids)
+
+ return self._response_for_validate_success(
+ completion_info_popup=completion_info_popup
+ )
def cancel(self, package_level_id):
package = self.env["stock.package_level"].browse(package_level_id)
@@ -260,7 +262,6 @@ def _states(self):
"confirm_start": self._schema_for_location,
"scan_location": self._schema_for_location,
"confirm_location": self._schema_for_location,
- "show_completion_info": {},
}
def start(self):
@@ -271,12 +272,7 @@ def cancel(self):
def validate(self):
return self._response_schema(
- next_states={
- "scan_location",
- "start",
- "confirm_location",
- "show_completion_info",
- }
+ next_states={"scan_location", "start", "confirm_location"}
)
@property
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
index db6d3bb3df..353cfa0b10 100644
--- a/shopfloor/services/validator.py
+++ b/shopfloor/services/validator.py
@@ -96,7 +96,12 @@ def _response_schema(self, data_schema=None, next_states=None):
},
"message": {"type": "string", "required": True},
},
- }
+ },
+ "popup": {
+ "type": "dict",
+ "required": False,
+ "schema": {"body": {"type": "string", "required": True}},
+ },
}
if not data_schema:
data_schema = {}
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 3342045245..e1483551ef 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -70,6 +70,7 @@ def setUpClass(cls):
def setUpClassVars(cls):
stock_location = cls.env.ref("stock.stock_location_stock")
cls.stock_location = stock_location
+ cls.customer_location = cls.env.ref("stock.stock_location_customers")
cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone")
cls.dispatch_location.barcode = "DISPATCH"
cls.packing_location = cls.env.ref("stock.location_pack_zone")
@@ -129,7 +130,9 @@ def setUpClassBaseData(cls):
{"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox"}
)
- def assert_response(self, response, next_state=None, message=None, data=None):
+ def assert_response(
+ self, response, next_state=None, message=None, data=None, popup=None
+ ):
"""Assert a response from the webservice
The data and message dictionaries can use ``self.ANY`` to accept any
@@ -138,6 +141,8 @@ def assert_response(self, response, next_state=None, message=None, data=None):
expected = {}
if message:
expected["message"] = message
+ if popup:
+ expected["popup"] = popup
if next_state:
expected.update(
{"next_state": next_state, "data": {next_state: data or {}}}
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 34de5253ad..337be573ae 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -5,6 +5,11 @@ class ClusterPickingUnloadingCommonCase(ClusterPickingCommonCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
+
+ # activate the computation of this field, so we have a chance to
+ # transition to the 'show completion info' popup.
+ cls.picking_type.display_completion_info = True
+
cls.batch = cls._create_picking_batch(
[
[
@@ -56,7 +61,6 @@ def test_prepare_unload_all_same_dest(self):
response = self.service.dispatch(
"prepare_unload", params={"picking_batch_id": self.batch.id}
)
- self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": True}])
self.assert_response(
response,
next_state="unload_all",
@@ -80,7 +84,6 @@ def test_prepare_unload_different_dest(self):
response = self.service.dispatch(
"prepare_unload", params={"picking_batch_id": self.batch.id}
)
- self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
first_line = move_lines[0]
self.assert_response(
response,
@@ -105,13 +108,6 @@ class ClusterPickingSetDestinationAllCase(ClusterPickingUnloadingCommonCase):
available line of a picking is unloaded, the picking is set to 'done'.
"""
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- # this is what the /prepare_endpoint method would have set as all the
- # destinations are the same:
- cls.batch.cluster_picking_unload_all = True
-
def test_set_destination_all_ok(self):
"""Set destination on all lines for the full batch and end the process"""
move_lines = self.batch.mapped("picking_ids.move_line_ids")
@@ -241,7 +237,6 @@ def test_set_destination_all_but_different_dest(self):
"barcode": self.packing_location.barcode,
},
)
- self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
self.assert_response(
response,
next_state="unload_single",
@@ -383,7 +378,6 @@ def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
# this is what the /prepare_endpoint method would have set as all the
# destinations are the same:
- cls.batch.cluster_picking_unload_all = True
def test_unload_split_ok(self):
"""Call /unload_split and continue to unload single"""
@@ -397,7 +391,6 @@ def test_unload_split_ok(self):
response = self.service.dispatch(
"unload_split", params={"picking_batch_id": self.batch.id}
)
- self.assertRecordValues(self.batch, [{"cluster_picking_unload_all": False}])
self.assert_response(
# the remaining move line still needs to be picked
response,
@@ -427,7 +420,6 @@ class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
- cls.batch.cluster_picking_unload_all = False
cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids")
cls._set_dest_package_and_done(cls.move_lines, cls.bin1)
cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id})
@@ -496,7 +488,6 @@ class ClusterPickingUnloadScanDestinationCase(ClusterPickingUnloadingCommonCase)
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
- cls.batch.cluster_picking_unload_all = False
cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids")
cls.bin1_lines = cls.move_lines[:1]
cls.bin2_lines = cls.move_lines[1:]
@@ -798,3 +789,50 @@ def test_unload_scan_destination_with_confirmation(self):
],
)
self.assert_response(response, next_state="unload_single", data=self.ANY)
+
+ def test_unload_scan_destination_completion_info(self):
+ """/unload_scan_destination that make chained picking ready"""
+ picking = self.one_line_picking
+ dest_location = picking.move_line_ids.location_dest_id
+ self.picking_type.display_completion_info = True
+
+ # create a chained picking after the current one
+ next_picking = picking.copy(
+ {
+ "picking_type_id": self.wh.out_type_id.id,
+ "location_id": picking.location_dest_id.id,
+ "location_dest_id": self.customer_location.id,
+ }
+ )
+ next_picking.move_lines.write(
+ {"move_orig_ids": [(6, 0, picking.move_lines.ids)]}
+ )
+ next_picking.action_confirm()
+
+ response = self.service.dispatch(
+ "unload_scan_destination",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin1.id,
+ "barcode": dest_location.barcode,
+ },
+ )
+
+ self.assert_response(
+ response,
+ next_state="unload_single",
+ popup={
+ "body": "Last operation of transfer {}. Next operation "
+ "({}) is ready to proceed.".format(picking.name, next_picking.name)
+ },
+ data={
+ "id": self.batch.id,
+ "name": self.batch.name,
+ # the line of bin1 is unloaded, next one will be bin2
+ "package": {"id": self.bin2.id, "name": self.bin2.name},
+ "location_dest": {
+ "id": self.bin2_lines[0].location_dest_id.id,
+ "name": self.bin2_lines[0].location_dest_id.name,
+ },
+ },
+ )
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 7cb8ff577d..88c78b4c45 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -396,6 +396,19 @@ def test_validate_completion_info(self):
# transition to the 'show completion info' screen.
self.picking_type.display_completion_info = True
+ # create a chained picking after the current one
+ next_picking = self.picking.copy(
+ {
+ "picking_type_id": self.wh.out_type_id.id,
+ "location_id": self.picking.location_dest_id.id,
+ "location_dest_id": self.customer_location.id,
+ }
+ )
+ next_picking.move_lines.write(
+ {"move_orig_ids": [(6, 0, self.picking.move_lines.ids)]}
+ )
+ next_picking.action_confirm()
+
# now, call the service to proceed with validation of the
# movement
response = self.service.dispatch(
@@ -408,7 +421,11 @@ def test_validate_completion_info(self):
self.assert_response(
response,
- next_state="show_completion_info",
+ next_state="start",
+ popup={
+ "body": "Last operation of transfer {}. Next operation "
+ "({}) is ready to proceed.".format(self.picking.name, next_picking.name)
+ },
message={
"message_type": "success",
"message": "The pack has been moved, you can scan a new pack.",
From 57e18361cce9893dbb180932e2c24262c1811b48 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 9 Mar 2020 14:20:24 +0100
Subject: [PATCH 201/940] Remove dead code
---
shopfloor/services/cluster_picking.py | 4 ----
1 file changed, 4 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 58abeccaee..94cab7bbee 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -350,10 +350,6 @@ def _next_line_for_pick(self, picking_batch):
def _response_batch_does_not_exist(self):
return self._response_for_start(message=self.msg_store.record_not_found())
- def _data_for_next_move_line(self, picking_batch):
- remaining_lines = self._lines_to_pick(picking_batch)
- return self._data_move_line(remaining_lines[0])
-
def _data_move_line(self, line):
picking = line.picking_id
batch = picking.batch_id
From d6e09a1fb0e17dbc3d2690dddafe15ed614be74b Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 7 May 2020 09:59:05 +0200
Subject: [PATCH 202/940] pack transfer: use _response_for_ pattern
As done in newer scenarii, use one _response_for_* method per state.
---
shopfloor/actions/pack_transfer_validate.py | 1 +
shopfloor/services/single_pack_putaway.py | 3 +
shopfloor/services/single_pack_transfer.py | 208 +++++++++-----------
3 files changed, 100 insertions(+), 112 deletions(-)
diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py
index 7de53656cd..7b80f0aae9 100644
--- a/shopfloor/actions/pack_transfer_validate.py
+++ b/shopfloor/actions/pack_transfer_validate.py
@@ -1,6 +1,7 @@
from odoo.addons.component.core import Component
+# TODO remove
# TODO think BETTER about how we want to share the common methods / workflows
class PackTransferValidateAction(Component):
"""Pack Transfer shared business logic
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 9b312ea2ee..0cd8c7747b 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -3,6 +3,9 @@
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
+# NOTE a lot of code is duplicated with SinglePackTransfer, but
+# this service will be replaced
+
class SinglePackPutaway(Component):
"""Methods for the Single Pack Put-Away Process"""
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index a226995420..8c029fc589 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -10,38 +10,9 @@ class SinglePackTransfer(Component):
_usage = "single_pack_transfer"
_description = __doc__
- # TODO get rid of these methods now that we have a component
- # for the messages? could help for extensibility though...?
- def _response_for_empty_location(self, location):
- message = self.actions_for("message")
- return self._response(
- next_state="start", message=message.no_pack_in_location(location)
- )
-
- def _response_for_several_packages(self, location):
- message = self.actions_for("message")
- return self._response(
- next_state="start", message=message.several_packs_in_location(location)
- )
-
- def _response_for_package_not_found(self, barcode):
- message = self.actions_for("message")
- return self._response(
- next_state="start", message=message.package_not_found_for_barcode(barcode)
- )
-
- def _response_for_forbidden_package(self, barcode, picking_types):
- message = self.actions_for("message")
- return self._response(
- next_state="start",
- message=message.package_not_allowed_in_src_location(barcode, picking_types),
- )
-
- def _response_for_operation_not_found(self, pack):
- message = self.actions_for("message")
- return self._response(
- next_state="start", message=message.no_pending_operation_for_pack(pack)
- )
+ @property
+ def msg_store(self):
+ return self.actions_for("message")
def _data_after_package_scanned(self, move_line, pack):
move = move_line.move_id
@@ -57,20 +28,33 @@ def _data_after_package_scanned(self, move_line, pack):
"picking": {"id": move.picking_id.id, "name": move.picking_id.name},
}
- def _response_for_start_to_confirm(self, move_line, pack):
- message = self.actions_for("message")
+ def _response_for_start(self, message=None, popup=None):
+ return self._response(next_state="start", message=message, popup=popup)
+
+ def _response_for_confirm_start(self, move_line, pack):
return self._response(
next_state="confirm_start",
- message=message.already_running_ask_confirmation(),
+ message=self.msg_store.already_running_ask_confirmation(),
data=self._data_after_package_scanned(move_line, pack),
)
- def _response_for_start_success(self, move_line, pack):
+ def _response_for_scan_location(self, move_line, pack, message=None):
return self._response(
next_state="scan_location",
data=self._data_after_package_scanned(move_line, pack),
)
+ def _response_for_confirm_location(self, move_line, pack, message=None):
+ message = self.actions_for("message")
+ return self._response(
+ next_state="confirm_location",
+ data=self._data_after_package_scanned(move_line, pack),
+ message=message,
+ )
+
+ def _response_for_show_completion_info(self, message=None):
+ return self._response(next_state="show_completion_info", message=message)
+
def start(self, barcode):
search = self.actions_for("search")
picking_types = self.picking_types
@@ -82,20 +66,30 @@ def start(self, barcode):
[("location_id", "=", location.id)]
)
if not pack:
- return self._response_for_empty_location(location)
+ return self._response_for_start(
+ message=self.msg_store.no_pack_in_location(location)
+ )
if len(pack) > 1:
- return self._response_for_several_packages(location)
+ return self._response_for_start(
+ message=self.msg_store.several_packs_in_location(location)
+ )
if not pack:
pack = search.package_from_scan(barcode)
if not pack:
- return self._response_for_package_not_found(barcode)
+ return self._response_for_start(
+ self.msg_store.package_not_found_for_barcode(barcode)
+ )
if not pack.location_id.is_sublocation_of(
picking_types.mapped("default_location_src_id")
):
- return self._response_for_forbidden_package(barcode, picking_types)
+ return self._response_for_start(
+ message=self.msg_store.package_not_allowed_in_src_location(
+ barcode, picking_types
+ )
+ )
existing_operations = self.env["stock.move.line"].search(
[
@@ -105,119 +99,109 @@ def start(self, barcode):
]
)
if not existing_operations:
- return self._response_for_operation_not_found(pack)
+ return self._response_for_start(
+ message=self.msg_store.no_pending_operation_for_pack(pack)
+ )
# TODO can we have more than one move line?
if existing_operations[0].package_level_id.is_done:
- return self._response_for_start_to_confirm(existing_operations, pack)
+ return self._response_for_confirm_start(existing_operations, pack)
existing_operations[0].package_level_id.is_done = True
- return self._response_for_start_success(existing_operations[0], pack)
+ return self._response_for_scan_location(existing_operations[0], pack)
- def _response_for_package_level_not_found(self):
- message = self.actions_for("message")
- return self._response(next_state="start", message=message.operation_not_found())
+ def _is_move_state_valid(self, move):
+ return move.state != "cancel"
- def _response_for_move_canceled_elsewhere(self):
- message = self.actions_for("message")
- return self._response(
- next_state="start", message=message.operation_has_been_canceled_elsewhere()
- )
-
- def _response_for_location_not_found(self, move_line, pack):
- message = self.actions_for("message")
- return self._response(
- next_state="scan_location",
- message=message.no_location_found(),
- data=self._data_after_package_scanned(move_line, pack),
- )
-
- def _response_for_forbidden_location(self, move_line, pack):
- message = self.actions_for("message")
- return self._response(
- next_state="scan_location",
- message=message.dest_location_not_allowed(),
- data=self._data_after_package_scanned(move_line, pack),
- )
-
- def _response_for_location_need_confirm(self, move_line, pack, to_location):
- message = self.actions_for("message")
- return self._response(
- next_state="confirm_location",
- message=message.confirm_location_changed(
- move_line.location_dest_id, to_location
- ),
- data=self._data_after_package_scanned(move_line, pack),
+ def _is_dest_location_valid(self, move, scanned_location):
+ """Forbid a dest location to be used"""
+ return scanned_location.is_sublocation_of(
+ move.picking_id.picking_type_id.default_location_dest_id
)
- def _response_for_validate_success(self, completion_info_popup=None):
- message = self.actions_for("message")
- return self._response(
- next_state="start",
- message=message.confirm_pack_moved(),
- popup=completion_info_popup,
- )
+ def _is_dest_location_to_confirm(self, move, scanned_location):
+ """Destination that could be used but need confirmation"""
+ move_dest_location = move.move_line_ids[0].location_dest_id
+ return not scanned_location.is_sublocation_of(move_dest_location)
def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
- # TODO this method is duplicated in putaway
- pack_transfer = self.actions_for("pack.transfer.validate")
search = self.actions_for("search")
+ message = self.actions_for("message")
package = self.env["stock.package_level"].browse(package_level_id)
if not package.exists():
- return self._response_for_package_level_not_found()
+ return self._response_for_start(message=message.operation_not_found())
move_line = package.move_line_ids[0]
move = move_line.move_id
- if not pack_transfer.is_move_state_valid(move):
- return self._response_for_move_canceled_elsewhere()
+ if not self._is_move_state_valid(move):
+ return self._response_for_start(
+ message=self.msg_store.operation_has_been_canceled_elsewhere()
+ )
scanned_location = search.location_from_scan(location_barcode)
if not scanned_location:
- return self._response_for_location_not_found(move_line, package.package_id)
- if not pack_transfer.is_dest_location_valid(move, scanned_location):
- return self._response_for_forbidden_location(move_line, package.package_id)
+ return self._response_for_scan_location(
+ move_line,
+ package.package_id,
+ message=self.msg_store.no_location_found(),
+ )
+ if not self._is_dest_location_valid(move, scanned_location):
+ return self._response_for_scan_location(
+ move_line,
+ package.package_id,
+ message=self.msg_store.dest_location_not_allowed(),
+ )
- if pack_transfer.is_dest_location_to_confirm(move, scanned_location):
+ if self._is_dest_location_to_confirm(move, scanned_location):
if confirmation:
# If the destination of the move would be incoherent
# (move line outside of it), we change the moves' destination
if not scanned_location.is_sublocation_of(move.location_dest_id):
move.location_dest_id = scanned_location.id
else:
- return self._response_for_location_need_confirm(
- move_line, package.package_id, scanned_location
+ return self._response_for_confirm_location(
+ move_line,
+ package.package_id,
+ message=self.msg_store.confirm_location_changed(
+ move_line.location_dest_id, scanned_location
+ ),
)
- pack_transfer.set_destination_and_done(move, scanned_location)
+ self._set_destination_and_done(move, scanned_location)
+ return self._router_validate_success(package)
- completion_info = self.actions_for("completion.info")
- completion_info_popup = completion_info.popup(package.move_line_ids)
+ def _is_last_move(self, move):
+ return move.picking_id.completion_info == "next_picking_ready"
- return self._response_for_validate_success(
- completion_info_popup=completion_info_popup
- )
+ def _router_validate_success(self, package_level):
+ move = package_level.move_line_ids.move_id
+
+ message = self.msg_store.confirm_pack_moved()
+
+ completion_info_popup = None
+ if self._is_last_move(move):
+ completion_info = self.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(package_level.move_line_ids)
+ return self._response_for_start(message=message, popup=completion_info_popup)
+
+ def _set_destination_and_done(self, move, scanned_location):
+ move.move_line_ids[0].location_dest_id = scanned_location.id
+ move._action_done()
def cancel(self, package_level_id):
+ message = self.actions_for("message")
package = self.env["stock.package_level"].browse(package_level_id)
if not package.exists():
- return self._response_for_package_level_not_found()
+ return self._response_for_start(message=message.operation_not_found())
# package.move_ids may be empty, it seems
move = package.move_line_ids.move_id
if move.state == "done":
- return self._response_for_move_already_processed()
+ return self._response_for_start(message=self.msg_store.already_done())
package.is_done = False
- return self._response_for_confirm_cancel()
-
- def _response_for_move_already_processed(self):
- message = self.actions_for("message")
- return self._response(next_state="start", message=message.already_done())
-
- def _response_for_confirm_cancel(self):
- message = self.actions_for("message")
- return self._response(
- next_state="start", message=message.confirm_canceled_scan_next_pack()
+ return self._response_for_start(
+ message=self.msg_store.confirm_canceled_scan_next_pack()
)
From 2201b67643ac5b38691a896594221c2beadd88d9 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 7 May 2020 15:20:39 +0200
Subject: [PATCH 203/940] pack transfer: refactor, use generic data structures
* use the generic data structures and schema
* review the method parameters, variable names
---
shopfloor/services/schema.py | 10 ++
shopfloor/services/single_pack_transfer.py | 154 ++++++++-----------
shopfloor/tests/test_single_pack_transfer.py | 53 ++++---
3 files changed, 99 insertions(+), 118 deletions(-)
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 2335aad540..762aa6d4c8 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -118,3 +118,13 @@ def picking_batch(self):
"move_line_count": {"required": True, "type": "integer"},
"weight": {"required": True, "nullable": True, "type": "float"},
}
+
+ def package_level(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "location_src": {"type": "dict", "schema": self.location()},
+ "location_dest": {"type": "dict", "schema": self.location()},
+ "product": {"type": "dict", "schema": self.product()},
+ "picking": {"type": "dict", "schema": self.picking()},
+ }
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 8c029fc589..196298a846 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -14,41 +14,43 @@ class SinglePackTransfer(Component):
def msg_store(self):
return self.actions_for("message")
- def _data_after_package_scanned(self, move_line, pack):
- move = move_line.move_id
+ @property
+ def data_struct(self):
+ return self.actions_for("data")
+
+ def _data_after_package_scanned(self, package_level):
+ move_line = package_level.move_line_ids[0]
+ package = package_level.package_id
return {
- "id": move_line.package_level_id.id,
- "name": pack.name,
- "location_src": {"id": pack.location_id.id, "name": pack.location_id.name},
- "location_dest": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
- "product": {"id": move.product_id.id, "name": move.product_id.name},
- "picking": {"id": move.picking_id.id, "name": move.picking_id.name},
+ "id": package_level.id,
+ "name": package.name,
+ "location_src": self.data_struct.location(move_line.location_id),
+ "location_dest": self.data_struct.location(package_level.location_dest_id),
+ "product": self.data_struct.product(move_line.product_id),
+ "picking": self.data_struct.picking(move_line.picking_id),
}
def _response_for_start(self, message=None, popup=None):
return self._response(next_state="start", message=message, popup=popup)
- def _response_for_confirm_start(self, move_line, pack):
+ def _response_for_confirm_start(self, package_level, message=None):
return self._response(
next_state="confirm_start",
- message=self.msg_store.already_running_ask_confirmation(),
- data=self._data_after_package_scanned(move_line, pack),
+ data=self._data_after_package_scanned(package_level),
+ message=message,
)
- def _response_for_scan_location(self, move_line, pack, message=None):
+ def _response_for_scan_location(self, package_level, message=None):
return self._response(
next_state="scan_location",
- data=self._data_after_package_scanned(move_line, pack),
+ data=self._data_after_package_scanned(package_level),
+ message=message,
)
- def _response_for_confirm_location(self, move_line, pack, message=None):
- message = self.actions_for("message")
+ def _response_for_confirm_location(self, package_level, message=None):
return self._response(
next_state="confirm_location",
- data=self._data_after_package_scanned(move_line, pack),
+ data=self._data_after_package_scanned(package_level),
message=message,
)
@@ -60,29 +62,29 @@ def start(self, barcode):
picking_types = self.picking_types
location = search.location_from_scan(barcode)
- pack = self.env["stock.quant.package"]
+ package = self.env["stock.quant.package"]
if location:
- pack = self.env["stock.quant.package"].search(
+ package = self.env["stock.quant.package"].search(
[("location_id", "=", location.id)]
)
- if not pack:
+ if not package:
return self._response_for_start(
message=self.msg_store.no_pack_in_location(location)
)
- if len(pack) > 1:
+ if len(package) > 1:
return self._response_for_start(
message=self.msg_store.several_packs_in_location(location)
)
- if not pack:
- pack = search.package_from_scan(barcode)
+ if not package:
+ package = search.package_from_scan(barcode)
- if not pack:
+ if not package:
return self._response_for_start(
self.msg_store.package_not_found_for_barcode(barcode)
)
- if not pack.location_id.is_sublocation_of(
+ if not package.location_id.is_sublocation_of(
picking_types.mapped("default_location_src_id")
):
return self._response_for_start(
@@ -91,23 +93,25 @@ def start(self, barcode):
)
)
- existing_operations = self.env["stock.move.line"].search(
+ move_lines = self.env["stock.move.line"].search(
[
- ("package_id", "=", pack.id),
- ("state", "!=", "done"),
+ ("package_id", "=", package.id),
+ ("state", "not in", ("cancel", "done")),
("picking_id.picking_type_id", "in", picking_types.ids),
]
)
- if not existing_operations:
+ if not move_lines:
return self._response_for_start(
- message=self.msg_store.no_pending_operation_for_pack(pack)
+ message=self.msg_store.no_pending_operation_for_pack(package)
+ )
+ if move_lines[0].package_level_id.is_done:
+ return self._response_for_confirm_start(
+ move_lines[0].package_level_id,
+ message=self.msg_store.already_running_ask_confirmation(),
)
- # TODO can we have more than one move line?
- if existing_operations[0].package_level_id.is_done:
- return self._response_for_confirm_start(existing_operations, pack)
- existing_operations[0].package_level_id.is_done = True
- return self._response_for_scan_location(existing_operations[0], pack)
+ move_lines[0].package_level_id.is_done = True
+ return self._response_for_scan_location(move_lines[0].package_level_id)
def _is_move_state_valid(self, move):
return move.state != "cancel"
@@ -126,13 +130,16 @@ def _is_dest_location_to_confirm(self, move, scanned_location):
def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
search = self.actions_for("search")
- message = self.actions_for("message")
- package = self.env["stock.package_level"].browse(package_level_id)
- if not package.exists():
- return self._response_for_start(message=message.operation_not_found())
+ package_level = self.env["stock.package_level"].browse(package_level_id)
+ if not package_level.exists():
+ return self._response_for_start(
+ message=self.msg_store.operation_not_found()
+ )
- move_line = package.move_line_ids[0]
+ # if we have more than one move, we should assume they go to the same
+ # place
+ move_line = package_level.move_line_ids[0]
move = move_line.move_id
if not self._is_move_state_valid(move):
return self._response_for_start(
@@ -142,15 +149,12 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
scanned_location = search.location_from_scan(location_barcode)
if not scanned_location:
return self._response_for_scan_location(
- move_line,
- package.package_id,
- message=self.msg_store.no_location_found(),
+ package_level, message=self.msg_store.no_location_found()
)
+
if not self._is_dest_location_valid(move, scanned_location):
return self._response_for_scan_location(
- move_line,
- package.package_id,
- message=self.msg_store.dest_location_not_allowed(),
+ package_level, message=self.msg_store.dest_location_not_allowed()
)
if self._is_dest_location_to_confirm(move, scanned_location):
@@ -161,15 +165,14 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
move.location_dest_id = scanned_location.id
else:
return self._response_for_confirm_location(
- move_line,
- package.package_id,
+ package_level,
message=self.msg_store.confirm_location_changed(
move_line.location_dest_id, scanned_location
),
)
self._set_destination_and_done(move, scanned_location)
- return self._router_validate_success(package)
+ return self._router_validate_success(package_level)
def _is_last_move(self, move):
return move.picking_id.completion_info == "next_picking_ready"
@@ -191,15 +194,15 @@ def _set_destination_and_done(self, move, scanned_location):
def cancel(self, package_level_id):
message = self.actions_for("message")
- package = self.env["stock.package_level"].browse(package_level_id)
- if not package.exists():
+ package_level = self.env["stock.package_level"].browse(package_level_id)
+ if not package_level.exists():
return self._response_for_start(message=message.operation_not_found())
# package.move_ids may be empty, it seems
- move = package.move_line_ids.move_id
+ move = package_level.move_line_ids.move_id
if move.state == "done":
return self._response_for_start(message=self.msg_store.already_done())
- package.is_done = False
+ package_level.is_done = False
return self._response_for_start(
message=self.msg_store.confirm_canceled_scan_next_pack()
)
@@ -243,9 +246,9 @@ def _states(self):
"""
return {
"start": {},
- "confirm_start": self._schema_for_location,
- "scan_location": self._schema_for_location,
- "confirm_location": self._schema_for_location,
+ "confirm_start": self._schema_for_package_level_details,
+ "scan_location": self._schema_for_package_level_details,
+ "confirm_location": self._schema_for_package_level_details,
}
def start(self):
@@ -260,36 +263,5 @@ def validate(self):
)
@property
- def _schema_for_location(self):
- return {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "location_src": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "location_dest": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "product": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "picking": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- }
+ def _schema_for_package_level_details(self):
+ return self.schemas().package_level()
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 88c78b4c45..348baf34ae 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -7,9 +7,6 @@ class SinglePackTransferCase(CommonCase):
@classmethod
def setUpClass(cls, *args, **kwargs):
super().setUpClass(*args, **kwargs)
- cls.product_a = cls.env["product.product"].create(
- {"name": "Product A", "type": "product"}
- )
cls.pack_a = cls.env["stock.quant.package"].create(
{"location_id": cls.stock_location.id}
)
@@ -60,6 +57,29 @@ def _simulate_started(self):
package_level.is_done = True
return package_level
+ def _response_package_level_data(self, package_level):
+ return {
+ "id": package_level.id,
+ "name": package_level.package_id.name,
+ "location_src": {"id": self.shelf1.id, "name": self.shelf1.name},
+ "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name},
+ "picking": {
+ "id": self.picking.id,
+ "name": self.picking.name,
+ "note": None,
+ "origin": None,
+ "partner": None,
+ "move_line_count": len(self.picking.move_line_ids),
+ "weight": 2.0,
+ },
+ "product": {
+ "id": self.product_a.id,
+ "name": self.product_a.name,
+ "default_code": self.product_a.default_code,
+ "display_name": self.product_a.display_name,
+ },
+ }
+
def test_start(self):
"""Test the happy path for single pack transfer /start endpoint
@@ -92,14 +112,7 @@ def test_start(self):
self.assert_response(
response,
next_state="scan_location",
- data={
- "id": self.ANY,
- "name": package_level.package_id.name,
- "location_src": {"id": self.shelf1.id, "name": self.shelf1.name},
- "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name},
- "picking": {"id": self.picking.id, "name": self.picking.name},
- "product": {"id": self.product_a.id, "name": self.product_a.name},
- },
+ data=self._response_package_level_data(package_level),
)
def test_start_no_operation(self):
@@ -315,14 +328,7 @@ def test_start_already_started(self):
"message": "Operation's already running."
" Would you like to take it over?",
},
- data={
- "id": self.ANY,
- "name": package_level.package_id.name,
- "location_src": {"id": self.shelf1.id, "name": self.shelf1.name},
- "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name},
- "picking": {"id": self.picking.id, "name": self.picking.name},
- "product": {"id": self.product_a.id, "name": self.product_a.name},
- },
+ data=self._response_package_level_data(package_level),
)
def test_validate(self):
@@ -556,14 +562,7 @@ def test_validate_location_to_confirm(self):
response,
next_state="confirm_location",
message=message,
- data={
- "id": self.ANY,
- "name": package_level.package_id.name,
- "location_src": {"id": self.shelf1.id, "name": self.shelf1.name},
- "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name},
- "picking": {"id": self.picking.id, "name": self.picking.name},
- "product": {"id": self.product_a.id, "name": self.product_a.name},
- },
+ data=self._response_package_level_data(package_level),
)
def test_validate_location_with_confirm(self):
From f864cac6ba615279fd674ee49f90fe8dfff7eb6b Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 7 May 2020 18:09:21 +0200
Subject: [PATCH 204/940] backend: add scan_anything endpoint
---
shopfloor/actions/search.py | 2 +-
shopfloor/services/__init__.py | 1 +
shopfloor/services/checkout.py | 2 +-
shopfloor/services/scan_anything.py | 115 ++++++++++++++++++++++++++
shopfloor/services/schema.py | 2 +-
shopfloor/tests/test_actions_data.py | 4 +-
shopfloor/tests/test_scan_anything.py | 74 +++++++++++++++++
7 files changed, 196 insertions(+), 4 deletions(-)
create mode 100644 shopfloor/services/scan_anything.py
create mode 100644 shopfloor/tests/test_scan_anything.py
diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py
index a547abbbc6..9b2beca215 100644
--- a/shopfloor/actions/search.py
+++ b/shopfloor/actions/search.py
@@ -18,7 +18,7 @@ def location_from_scan(self, barcode):
def package_from_scan(self, barcode):
return self.env["stock.quant.package"].search([("name", "=", barcode)])
- def stock_picking_from_scan(self, barcode):
+ def picking_from_scan(self, barcode):
return self.env["stock.picking"].search([("name", "=", barcode)])
def product_from_scan(self, barcode):
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 18e7b56db6..7dee66d21c 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -7,6 +7,7 @@
from . import app
from . import menu
from . import profile
+from . import scan_anything
# process services
from . import checkout
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index ba3c0f4225..cc0b18363e 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -58,7 +58,7 @@ def scan_document(self, barcode):
"""
search = self.actions_for("search")
message = self.actions_for("message")
- picking = search.stock_picking_from_scan(barcode)
+ picking = search.picking_from_scan(barcode)
if not picking:
location = search.location_from_scan(barcode)
if location:
diff --git a/shopfloor/services/scan_anything.py b/shopfloor/services/scan_anything.py
new file mode 100644
index 0000000000..410445fdb3
--- /dev/null
+++ b/shopfloor/services/scan_anything.py
@@ -0,0 +1,115 @@
+from odoo import _
+
+from odoo.addons.component.core import Component
+
+
+class ShopfloorScanAnything(Component):
+ """Endpoints to scan any record.
+
+ Supported types of records (models):
+
+ * location (stock.location)
+ * package (stock.quant.package)
+ * product (product.product)
+ * lot (stock.production.lot)
+ * transfer (stock.picking)
+
+ NOTE for swagger docs: using `anyof_schema` for `record` response key
+ does not work in swagger UI. Hence, you won't see any detail.
+
+ Issue: https://github.com/swagger-api/swagger-ui/issues/3803
+ PR: https://github.com/swagger-api/swagger-ui/pull/5530
+ """
+
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.scan.anything"
+ _usage = "scan_anything"
+ _description = __doc__
+
+ def scan(self, identifier):
+ # TODO: shall we add constrains by profile etc?
+ data = {}
+ tried = []
+ record = None
+ for rec_type, finder, converter, __ in self._scan_handlers():
+ tried.append(rec_type)
+ record = finder(identifier)
+ if record:
+ data.update(
+ {
+ "identifier": identifier,
+ "record": converter(record),
+ "type": rec_type,
+ }
+ )
+ break
+ if not record:
+ return self._response_for_not_found(tried)
+ return self._response_for_found(data)
+
+ def _response_for_found(self, data):
+ return self._response(data=data)
+
+ def _response_for_not_found(self, tried):
+ message = {
+ "message": _(
+ "Record not found.\n" "We've tried with the following types: {}"
+ ).format(", ".join(tried)),
+ "message_type": "error",
+ }
+ return self._response(message=message)
+
+ def _scan_handlers(self):
+ search = self.actions_for("search")
+ data = self.actions_for("data")
+ schema = self.component(usage="schema")
+ return (
+ ("location", search.location_from_scan, data.location, schema.location),
+ ("package", search.package_from_scan, data.package, schema.package),
+ ("product", search.product_from_scan, data.product, schema.product),
+ ("lot", search.lot_from_scan, data.lot, schema.lot),
+ ("transfer", search.picking_from_scan, data.picking, schema.picking),
+ )
+
+
+class ShopfloorScanAnythingValidator(Component):
+ """Validators for the Application endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.scan_anything.validator"
+ _usage = "scan_anything.validator"
+
+ def scan(self):
+ return {
+ "identifier": {"type": "string", "nullable": False, "required": True},
+ }
+
+
+class ShopfloorScanAnythingValidatorResponse(Component):
+ """Validators for the scan anything endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.scan_anything.validator.response"
+ _usage = "scan_anything.validator.response"
+
+ def scan(self):
+ scan_service = self.component(usage="scan_anything")
+ allowed_types = [x[0] for x in scan_service._scan_handlers()]
+ allowed_schemas = [x[-1]() for x in scan_service._scan_handlers()]
+ data_schema = {
+ "identifier": {"type": "string", "nullable": True, "required": False},
+ "type": {
+ "type": "string",
+ "nullable": True,
+ "required": False,
+ "allowed": allowed_types,
+ },
+ "record": {
+ "type": "dict",
+ "required": False,
+ "nullable": True,
+ "anyof_schema": allowed_schemas,
+ "dependencies": ["identifier", "type"],
+ },
+ }
+ return self._response_schema(data_schema)
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 762aa6d4c8..4bdd4a9fda 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -82,7 +82,7 @@ def package(self):
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"weight": {"required": True, "nullable": True, "type": "float"},
- "move_line_count": {"required": True, "nullable": True, "type": "integer"},
+ "move_line_count": {"required": False, "nullable": True, "type": "integer"},
"packaging": {
"type": "dict",
"required": True,
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 8adf3ba9bb..274f28289b 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -11,7 +11,7 @@
_logger.debug("Can not import cerberus")
-class ActionsDataCase(CommonCase):
+class ActionsDataCaseBase(CommonCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
@@ -42,6 +42,8 @@ def setUpClass(cls):
cls._fill_stock_for_moves(cls.move_d)
cls.picking.action_assign()
+
+class ActionsDataCase(ActionsDataCaseBase):
def setUp(self):
super().setUp()
with self.work_on_actions() as work:
diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py
new file mode 100644
index 0000000000..782b73cad6
--- /dev/null
+++ b/shopfloor/tests/test_scan_anything.py
@@ -0,0 +1,74 @@
+from .test_actions_data import ActionsDataCaseBase
+
+
+class ScanAnythingCase(ActionsDataCaseBase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.package = cls.move_a.move_line_ids.package_id
+ cls.lot = cls.env["stock.production.lot"].create(
+ {"product_id": cls.product_b.id, "company_id": cls.env.company.id}
+ )
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_actions() as work:
+ self.data = work.component(usage="data")
+ with self.work_on_services() as work:
+ self.service = work.component(usage="scan_anything")
+
+ def _test_response_ok(self, rec_type, data, identifier):
+ params = {"identifier": identifier}
+ response = self.service.dispatch("scan", params=params)
+ self.assert_response(
+ response, data={"type": rec_type, "identifier": identifier, "record": data},
+ )
+
+ def _test_response_ko(self, identifier, tried=None):
+ tried = tried or [x[0] for x in self.service._scan_handlers()]
+ params = {"identifier": identifier}
+ response = self.service.dispatch("scan", params=params)
+ message = response["message"]
+ self.assertEqual(message["message_type"], "error")
+ self.assertIn("Record not found", message["message"])
+ for rec_type in tried:
+ self.assertIn(rec_type, message["message"])
+
+ def test_scan_product(self):
+ record = self.product_b
+ record.barcode = "PROD-B"
+ rec_type = "product"
+ identifier = record.barcode
+ data = self.data.product(record)
+ self._test_response_ok(rec_type, data, identifier)
+
+ def test_scan_location(self):
+ record = self.stock_location
+ rec_type = "location"
+ identifier = record.barcode
+ data = self.data.location(record)
+ self._test_response_ok(rec_type, data, identifier)
+
+ def test_scan_package(self):
+ record = self.package
+ rec_type = "package"
+ identifier = record.name
+ data = self.data.package(record)
+ self._test_response_ok(rec_type, data, identifier)
+
+ def test_scan_lot(self):
+ record = self.lot
+ rec_type = "lot"
+ identifier = record.name
+ data = self.data.lot(record)
+ self._test_response_ok(rec_type, data, identifier)
+
+ def test_scan_transfer(self):
+ record = self.picking
+ rec_type = "transfer"
+ identifier = record.name
+ data = self.data.picking(record)
+ self._test_response_ok(rec_type, data, identifier)
+
+ def test_scan_error(self):
+ self._test_response_ko("404-NOTFOUND")
From e55e81bc83c618febc85e1d0fa6e141ed4466f8c Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 8 May 2020 10:20:36 +0200
Subject: [PATCH 205/940] backend: fix non-process services api-docs
Some headers are required only for specific services.
Use a specific base component + flags to handle this properly
---
shopfloor/services/app.py | 3 +
shopfloor/services/checkout.py | 2 +-
shopfloor/services/cluster_picking.py | 2 +-
shopfloor/services/delivery.py | 2 +-
shopfloor/services/service.py | 102 ++++++++++++---------
shopfloor/services/single_pack_putaway.py | 2 +-
shopfloor/services/single_pack_transfer.py | 2 +-
7 files changed, 67 insertions(+), 48 deletions(-)
diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py
index 0a87f0b6e3..d9dcab6ef9 100644
--- a/shopfloor/services/app.py
+++ b/shopfloor/services/app.py
@@ -8,6 +8,9 @@ class ShopfloorApp(Component):
_name = "shopfloor.app"
_usage = "app"
_description = __doc__
+ # TODO this is required only for `menu` and not for `user_config`
+ # Maybe we should split them.
+ _requires_header_profile = True
def user_config(self):
profiles_comp = self.component("profile")
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index cc0b18363e..e5afa78e24 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -29,7 +29,7 @@ class Checkout(Component):
Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
"""
- _inherit = "base.shopfloor.service"
+ _inherit = "base.shopfloor.process"
_name = "shopfloor.checkout"
_usage = "checkout"
_description = __doc__
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 94cab7bbee..f3ba03b31a 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -66,7 +66,7 @@ class ClusterPicking(Component):
Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
"""
- _inherit = "base.shopfloor.service"
+ _inherit = "base.shopfloor.process"
_name = "shopfloor.cluster.picking"
_usage = "cluster_picking"
_description = __doc__
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 3a33be7b3f..257910746f 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -25,7 +25,7 @@ class Delivery(Component):
Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
"""
- _inherit = "base.shopfloor.service"
+ _inherit = "base.shopfloor.process"
_name = "shopfloor.delivery"
_usage = "delivery"
_description = __doc__
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index b1c8d4f512..66a7702f43 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -23,23 +23,6 @@ class BaseShopfloorService(AbstractComponent):
_actions_collection_name = "shopfloor.action"
_expose_model = None
- @property
- def picking_types(self):
- """Return picking types for the menu and profile"""
- # TODO make this a lazy property or computed field avoid running the
- # filter every time?
- picking_types = self.work.menu.picking_type_ids.filtered(
- lambda pt: not pt.warehouse_id
- or pt.warehouse_id == self.work.profile.warehouse_id
- )
- if not picking_types:
- raise exceptions.UserError(
- _("No operation types configured on menu {} for warehouse {}.").format(
- self.work.menu.name, self.work.profile.warehouse_id.display_name
- )
- )
- return picking_types
-
def _get(self, _id):
domain = expression.normalize_domain(self._get_base_search_domain())
domain = expression.AND([domain, [("id", "=", _id)]])
@@ -128,31 +111,33 @@ def _response(
return response
+ _requires_header_menu = False
+ _requires_header_profile = False
+
def _get_openapi_default_parameters(self):
defaults = super()._get_openapi_default_parameters()
demo_api_key = self.env.ref("shopfloor.api_key_demo", raise_if_not_found=False)
-
- # Try to first the first menu that implements the current service.
- # Not all usages have a process, in that case, we'll set the first
- # menu found
- menu = self.env["shopfloor.menu"].search(
- [("scenario", "=", self._usage)], limit=1
- )
- if not menu:
- menu = self.env["shopfloor.menu"].search([], limit=1)
- profile = self.env["shopfloor.profile"].search([], limit=1)
-
- defaults.extend(
- [
- {
- "name": "API-KEY",
- "in": "header",
- "description": "API key for Authorization",
- "required": True,
- "schema": {"type": "string"},
- "style": "simple",
- "value": demo_api_key.key if demo_api_key else "",
- },
+ service_params = [
+ {
+ "name": "API-KEY",
+ "in": "header",
+ "description": "API key for Authorization",
+ "required": True,
+ "schema": {"type": "string"},
+ "style": "simple",
+ "value": demo_api_key.key if demo_api_key else "",
+ },
+ ]
+ if self._requires_header_menu:
+ # Try to first the first menu that implements the current service.
+ # Not all usages have a process, in that case, we'll set the first
+ # menu found
+ menu = self.env["shopfloor.menu"].search(
+ [("scenario", "=", self._usage)], limit=1
+ )
+ if not menu:
+ menu = self.env["shopfloor.menu"].search([], limit=1)
+ service_params.append(
{
"name": "SERVICE_CTX_MENU_ID",
"in": "header",
@@ -161,7 +146,11 @@ def _get_openapi_default_parameters(self):
"schema": {"type": "integer"},
"style": "simple",
"value": menu.id,
- },
+ }
+ )
+ if self._requires_header_profile:
+ profile = self.env["shopfloor.profile"].search([], limit=1)
+ service_params.append(
{
"name": "SERVICE_CTX_PROFILE_ID",
"in": "header",
@@ -170,9 +159,9 @@ def _get_openapi_default_parameters(self):
"schema": {"type": "integer"},
"style": "simple",
"value": profile.id,
- },
- ]
- )
+ }
+ ),
+ defaults.extend(service_params)
return defaults
@property
@@ -201,3 +190,30 @@ def _is_public_api_method(self, method_name):
if method_name == "actions_for":
return False
return super()._is_public_api_method(method_name)
+
+
+class BaseShopfloorProcess(AbstractComponent):
+ """Base class for process rest service"""
+
+ _inherit = "base.shopfloor.service"
+ _name = "base.shopfloor.process"
+
+ _requires_header_menu = True
+ _requires_header_profile = True
+
+ @property
+ def picking_types(self):
+ """Return picking types for the menu and profile"""
+ # TODO make this a lazy property or computed field avoid running the
+ # filter every time?
+ picking_types = self.work.menu.picking_type_ids.filtered(
+ lambda pt: not pt.warehouse_id
+ or pt.warehouse_id == self.work.profile.warehouse_id
+ )
+ if not picking_types:
+ raise exceptions.UserError(
+ _("No operation types configured on menu {} for warehouse {}.").format(
+ self.work.menu.name, self.work.profile.warehouse_id.display_name
+ )
+ )
+ return picking_types
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 0cd8c7747b..3473b215ce 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -10,7 +10,7 @@
class SinglePackPutaway(Component):
"""Methods for the Single Pack Put-Away Process"""
- _inherit = "base.shopfloor.service"
+ _inherit = "base.shopfloor.process"
_name = "shopfloor.single.pack.putaway"
_usage = "single_pack_putaway"
_description = __doc__
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 196298a846..fc7e9465fe 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -5,7 +5,7 @@
class SinglePackTransfer(Component):
"""Methods for the Single Pack Transfer Process"""
- _inherit = "base.shopfloor.service"
+ _inherit = "base.shopfloor.process"
_name = "shopfloor.single.pack.transfer"
_usage = "single_pack_transfer"
_description = __doc__
From 3a241fd46d3654d1b39954f38e5c962f52d76f73 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 11 May 2020 07:55:02 +0200
Subject: [PATCH 206/940] backend: add data_detail component
---
shopfloor/__manifest__.py | 12 +
shopfloor/actions/__init__.py | 2 +-
shopfloor/actions/data.py | 25 +-
shopfloor/actions/data_detail.py | 145 ++++++++
shopfloor/controllers/main.py | 4 +
shopfloor/models/stock_location.py | 16 +
shopfloor/models/stock_quant_package.py | 16 +-
shopfloor/services/__init__.py | 1 +
shopfloor/services/cluster_picking.py | 4 +-
shopfloor/services/scan_anything.py | 44 ++-
shopfloor/services/schema_detail.py | 131 +++++++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_actions_data.py | 8 +-
shopfloor/tests/test_actions_data_detail.py | 385 ++++++++++++++++++++
shopfloor/tests/test_scan_anything.py | 24 +-
15 files changed, 777 insertions(+), 41 deletions(-)
create mode 100644 shopfloor/actions/data_detail.py
create mode 100644 shopfloor/services/schema_detail.py
create mode 100644 shopfloor/tests/test_actions_data_detail.py
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 3e58a73793..f9e2462919 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -23,6 +23,18 @@
"stock_quant_package_dimension",
# https://github.com/OCA/stock-logistics-workflow/pull/607
"stock_quant_package_product_packaging",
+ # TODO: used for manuf info on prod detail.
+ # This must be an optional dep
+ "product_manufacturer",
+ # TODO: used for prod lot expire detail info.
+ # This must be an optional dep
+ "product_expiry",
+ # TODO: used for package.package_storage_type_id detail info.
+ # This must be an optional dep
+ "stock_storage_type",
+ # TODO: used for picking.carrier_id detail info.
+ # This must be an optional dep
+ "delivery",
],
"data": [
"security/ir.model.access.csv",
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index a5971aad7c..58c70c29f3 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -18,8 +18,8 @@
"""
from . import base_action
from . import data
+from . import data_detail
from . import completion_info
-
from . import message
from . import pack_transfer_validate
from . import search
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index e71c9a5db9..578f2ab5d7 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -28,6 +28,18 @@ def partners(self, record, **kw):
def _partner_parser(self):
return ["id", "name"]
+ def location(self, record, **kw):
+ return self._jsonify(
+ record.with_context(location=record.id), self._location_parser, **kw
+ )
+
+ def locations(self, record, **kw):
+ return self.location(record, multi=True)
+
+ @property
+ def _location_parser(self):
+ return ["id", "name"]
+
def picking(self, record, **kw):
return self._jsonify(record, self._picking_parser, **kw)
@@ -92,21 +104,14 @@ def lots(self, record, **kw):
def _lot_parser(self):
return ["id", "name", "ref"]
- def location(self, record, **kw):
- return self._jsonify(record, self._location_parser, **kw)
-
- def locations(self, record, **kw):
- return self.location(record, multi=True)
-
- @property
- def _location_parser(self):
- return ["id", "name"]
-
def move_line(self, record, **kw):
+ record = record.with_context(location=record.location_id.id)
data = self._jsonify(record, self._move_line_parser)
if data:
data.update(
{
+ # cannot use sub-parser here
+ # because result might depend on picking
"package_src": self.package(record.package_id, record.picking_id),
"package_dest": self.package(
record.result_package_id, record.picking_id,
diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py
new file mode 100644
index 0000000000..bb260db544
--- /dev/null
+++ b/shopfloor/actions/data_detail.py
@@ -0,0 +1,145 @@
+from odoo.tools.float_utils import float_round
+
+from odoo.addons.component.core import Component
+
+
+class DataDetailAction(Component):
+ """Provide extra data on top of data action.
+ """
+
+ _name = "shopfloor.data.detail.action"
+ _inherit = "shopfloor.data.action"
+ _usage = "data_detail"
+
+ def _select_value_to_label(self, rec, fname):
+ return rec._fields[fname].convert_to_export(rec[fname], rec)
+
+ def location_detail(self, record, **kw):
+ return self._jsonify(
+ record.with_context(location=record.id), self._location_detail_parser, **kw
+ )
+
+ def locations_detail(self, record, **kw):
+ return self.location_detail(record, multi=True)
+
+ @property
+ def _location_detail_parser(self):
+ return self._location_parser + [
+ "complete_name",
+ (
+ "reserved_move_line_ids:reserved_move_lines",
+ lambda record, fname: self.move_lines(record[fname]),
+ ),
+ ]
+
+ def picking_detail(self, record, **kw):
+ return self._jsonify(record, self._picking_detail_parser, **kw)
+
+ def pickings_detail(self, record, **kw):
+ return self.picking_detail(record, multi=True)
+
+ @property
+ def _picking_detail_parser(self):
+ return self._picking_parser + [
+ ("priority", self._select_value_to_label),
+ "scheduled_date",
+ ("picking_type_id:operation_type", ["id", "name"]),
+ ("carrier_id:carrier", ["id", "name"]),
+ (
+ "move_line_ids:lines",
+ lambda record, fname: self.move_lines(record[fname]),
+ ),
+ ]
+
+ def package_detail(self, record, picking=None, **kw):
+ # Define a new method to not overload the base one which is used in many places
+ data = self.package(record, picking=picking, **kw)
+ data.update(self._jsonify(record, self._package_detail_parser, **kw))
+ return data
+
+ def packages_detail(self, records, picking=None, **kw):
+ return [self.package_detail(rec, picking=picking) for rec in records]
+
+ @property
+ def _package_detail_parser(self):
+ return [
+ (
+ "reserved_move_line_ids:pickings",
+ lambda record, fname: self.pickings(record[fname].mapped("picking_id")),
+ ),
+ (
+ "reserved_move_line_ids:lines",
+ lambda record, fname: self.move_lines(record[fname]),
+ ),
+ ("package_storage_type_id:storage_type", ["id", "name"]),
+ ]
+
+ def lot_detail(self, record, **kw):
+ # Define a new method to not overload the base one which is used in many places
+ return self._jsonify(record, self._lot_detail_parser, **kw)
+
+ def lots_detail(self, record, **kw):
+ return self.lot_detail(record, multi=True)
+
+ @property
+ def _lot_detail_parser(self):
+ return self._lot_parser + [
+ "removal_date",
+ "life_date:expire_date",
+ (
+ "product_id:product",
+ lambda record, fname: self.product_detail(record[fname]),
+ ),
+ ]
+
+ def product_detail(self, record, **kw):
+ # Defined new method to not overload the base one used in many places
+ data = self._jsonify(record, self._product_detail_parser, **kw)
+ suppliers = self.env["product.supplierinfo"].search(
+ [("product_id", "=", record.id)]
+ )
+ data["suppliers"] = suppliers.jsonify(self._product_supplierinfo_parser)
+ return data
+
+ def products_detail(self, record, **kw):
+ return self.product_detail(record, multi=True)
+
+ @property
+ def _product_parser(self):
+ return super()._product_parser + [
+ "qty_available",
+ ("free_qty:qty_reserved", self._product_reserved_qty_subparser),
+ ]
+
+ def _product_reserved_qty_subparser(self, rec, field_name):
+ # free_qty = qty_available - reserved_quantity
+ return float_round(
+ rec.qty_available - rec[field_name], precision_rounding=rec.uom_id.rounding
+ )
+
+ @property
+ def _product_detail_parser(self):
+ return self._product_parser + [
+ ("image_128:image", self._product_image_url),
+ (
+ "product_tmpl_id:manufacturer",
+ lambda rec, fname: self._jsonify(
+ rec.product_tmpl_id.manufacturer, ["id", "name"]
+ ),
+ ),
+ ]
+
+ def _product_image_url(self, record, field_name):
+ if not record[field_name]:
+ return None
+ return "/web/image/product.product/{}/{}".format(record.id, field_name)
+
+ @property
+ def _product_supplierinfo_parser(self):
+ return [
+ # supplier.name == partner :/
+ ("id", lambda rec, fname: rec.name.id),
+ ("name", lambda rec, fname: rec.name.name),
+ "product_name",
+ "product_code",
+ ]
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index c5330310e9..3f04afa240 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -16,9 +16,13 @@ class ShopfloorController(main.RestController):
_root_path = "/shopfloor/"
_collection_name = "shopfloor.service"
_default_auth = "api_key"
+ # TODO: this should come from registered services.
+ # We would need to change how their ctx is initialized tho
+ # because ATM the ctx is computed before lookup.
_service_headers_rules = {
# no special header required for config
"app/user_config": (),
+ "scan_anything/scan": (),
# profile header is required to get menu items
# fmt: off
# NOTE: turn off formatting here is mandatory
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
index 6c9ce5962a..cb3e8b8856 100644
--- a/shopfloor/models/stock_location.py
+++ b/shopfloor/models/stock_location.py
@@ -7,6 +7,9 @@ class StockLocation(models.Model):
source_move_line_ids = fields.One2many(
comodel_name="stock.move.line", inverse_name="location_id", readonly=True
)
+ reserved_move_line_ids = fields.One2many(
+ comodel_name="stock.move.line", compute="_compute_reserved_move_lines",
+ )
def is_sublocation_of(self, others):
"""Return True if self is a sublocation of at least one other"""
@@ -16,3 +19,16 @@ def is_sublocation_of(self, others):
[("id", "child_of", others.ids), ("id", "=", self.id)]
)
)
+
+ def _get_reserved_move_lines(self):
+ return self.env["stock.move.line"].search(
+ [
+ ("location_id", "=", self.id),
+ ("product_uom_qty", ">", 0),
+ ("state", "not in", ("done", "cancel")),
+ ]
+ )
+
+ def _compute_reserved_move_lines(self):
+ for rec in self:
+ rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()})
diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py
index 4716a95bb1..41547158cf 100644
--- a/shopfloor/models/stock_quant_package.py
+++ b/shopfloor/models/stock_quant_package.py
@@ -1,4 +1,4 @@
-from odoo import fields, models
+from odoo import api, fields, models
class StockQuantPackage(models.Model):
@@ -17,6 +17,20 @@ class StockQuantPackage(models.Model):
readonly=True,
help="Technical field. Move lines for which destination is this package.",
)
+ # TODO: review other fields
+ reserved_move_line_ids = fields.One2many(
+ comodel_name="stock.move.line", compute="_compute_reserved_move_lines",
+ )
+
+ def _get_reserved_move_lines(self):
+ return self.env["stock.move.line"].search(
+ [("package_id", "=", self.id), ("state", "not in", ("done", "cancel"))]
+ )
+
+ @api.depends("move_line_ids.state")
+ def _compute_reserved_move_lines(self):
+ for rec in self:
+ rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()})
# TODO: we should refactor this like
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 7dee66d21c..dccb05c842 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -2,6 +2,7 @@
from . import service
from . import validator
from . import schema
+from . import schema_detail
# generic services
from . import app
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index f3ba03b31a..849faa01da 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -360,7 +360,9 @@ def _data_move_line(self, line):
data["batch"] = self.data_struct.picking_batch(batch)
data["picking"] = self.data_struct.picking(picking)
data["postponed"] = line.shopfloor_postponed
- data["product"]["qty_available"] = product.qty_available
+ data["product"]["qty_available"] = product.with_context(
+ location=line.location_id.id
+ ).qty_available
return data
def unassign(self, picking_batch_id):
diff --git a/shopfloor/services/scan_anything.py b/shopfloor/services/scan_anything.py
index 410445fdb3..9cfe8be26d 100644
--- a/shopfloor/services/scan_anything.py
+++ b/shopfloor/services/scan_anything.py
@@ -60,15 +60,45 @@ def _response_for_not_found(self, tried):
return self._response(message=message)
def _scan_handlers(self):
+ """Return a tuple of tuples describing handlers for scan requests.
+
+ Tuple schema:
+
+ 0. record type
+ 1. finder
+ 2. json detail converter
+ 3. detail schema validator
+
+ """
search = self.actions_for("search")
- data = self.actions_for("data")
- schema = self.component(usage="schema")
+ data = self.actions_for("data_detail")
+ schema = self.component(usage="schema_detail")
return (
- ("location", search.location_from_scan, data.location, schema.location),
- ("package", search.package_from_scan, data.package, schema.package),
- ("product", search.product_from_scan, data.product, schema.product),
- ("lot", search.lot_from_scan, data.lot, schema.lot),
- ("transfer", search.picking_from_scan, data.picking, schema.picking),
+ (
+ "location",
+ search.location_from_scan,
+ data.location_detail,
+ schema.location_detail,
+ ),
+ (
+ "package",
+ search.package_from_scan,
+ data.package_detail,
+ schema.package_detail,
+ ),
+ (
+ "product",
+ search.product_from_scan,
+ data.product_detail,
+ schema.product_detail,
+ ),
+ ("lot", search.lot_from_scan, data.lot_detail, schema.lot_detail),
+ (
+ "transfer",
+ search.picking_from_scan,
+ data.picking_detail,
+ schema.picking_detail,
+ ),
)
diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py
new file mode 100644
index 0000000000..435b108d5e
--- /dev/null
+++ b/shopfloor/services/schema_detail.py
@@ -0,0 +1,131 @@
+from odoo.addons.component.core import Component
+
+
+class ShopfloorSchemaDetailResponse(Component):
+ """Provide methods to share schema structures
+
+ The methods should be used in Service Components, so we try to
+ have similar schema structures across scenarios.
+ """
+
+ _inherit = "base.shopfloor.schemas"
+ _name = "base.shopfloor.schemas.detail"
+ _usage = "schema_detail"
+
+ def _schema_list_of(self, schema, **kw):
+ return {
+ "type": "list",
+ "nullable": True,
+ "required": False,
+ "schema": {"type": "dict", "schema": schema},
+ }
+
+ def _simple_record(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ }
+
+ def _schema_dict_of(self, schema, **kw):
+ schema = {
+ "type": "dict",
+ "nullable": True,
+ "required": False,
+ "schema": schema,
+ }
+ schema.update(kw)
+ return schema
+
+ def location_detail(self):
+ schema = self.location()
+ schema.update(
+ {
+ "complete_name": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ "reserved_move_lines": self._schema_list_of(self.move_line()),
+ }
+ )
+ return schema
+
+ def picking_detail(self):
+ schema = self.picking()
+ schema.update(
+ {
+ "priority": {"type": "string", "nullable": True, "required": False},
+ "scheduled_date": {
+ "type": "string",
+ "nullable": False,
+ "required": True,
+ },
+ "operation_type": self._schema_dict_of(self._simple_record()),
+ "carrier": self._schema_dict_of(self._simple_record()),
+ "lines": self._schema_list_of(self.move_line()),
+ }
+ )
+ return schema
+
+ def package_detail(self):
+ schema = self.package()
+ schema.update(
+ {
+ "pickings": self._schema_list_of(self.picking()),
+ "storage_type": self._schema_dict_of(self._simple_record()),
+ "lines": self._schema_list_of(self.move_line()),
+ }
+ )
+ return schema
+
+ def lot_detail(self):
+ schema = self.lot()
+ schema.update(
+ {
+ "removal_date": {"type": "string", "nullable": True, "required": False},
+ "expire_date": {"type": "string", "nullable": True, "required": False},
+ "product": self._schema_dict_of(self.product_detail()),
+ # TODO: packaging
+ }
+ )
+ return schema
+
+ def product(self):
+ schema = super().product()
+ schema.update(
+ {
+ "qty_available": {"type": "float", "required": True},
+ "qty_reserved": {"type": "float", "required": True},
+ }
+ )
+ return schema
+
+ def product_detail(self):
+ schema = self.product()
+ schema.update(
+ {
+ "image": {"type": "string", "nullable": True, "required": False},
+ "manufacturer": self._schema_dict_of(self._simple_record()),
+ "suppliers": self._schema_list_of(self.product_supplierinfo()),
+ }
+ )
+ return schema
+
+ def product_supplierinfo(self):
+ schema = self._simple_record()
+ schema.update(
+ {
+ "product_name": {"type": "string", "nullable": True, "required": False},
+ "product_code": {"type": "string", "nullable": True, "required": False},
+ }
+ )
+ return schema
+
+ # TODO
+ # def packaging_detail(self):
+ # schema = self.packaging()
+ # schema.update(
+ # {
+ # }
+ # )
+ # return schema
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 7994d4bf51..f6c6713ffd 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -3,6 +3,7 @@
from . import test_openapi
from . import test_profile
from . import test_actions_data
+from . import test_actions_data_detail
from . import test_single_pack_putaway
from . import test_single_pack_transfer
from . import test_cluster_picking_base
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 274f28289b..ca3678d3cb 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -42,6 +42,10 @@ def setUpClass(cls):
cls._fill_stock_for_moves(cls.move_d)
cls.picking.action_assign()
+ def assert_schema(self, schema, data):
+ validator = Validator(schema)
+ self.assertTrue(validator.validate(data), validator.errors)
+
class ActionsDataCase(ActionsDataCaseBase):
def setUp(self):
@@ -51,10 +55,6 @@ def setUp(self):
with self.work_on_services() as work:
self.schema = work.component(usage="schema")
- def assert_schema(self, schema, data):
- validator = Validator(schema)
- self.assertTrue(validator.validate(data), validator.errors)
-
def test_data_packaging(self):
data = self.data.packaging(self.packaging)
self.assert_schema(self.schema.packaging(), data)
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
new file mode 100644
index 0000000000..f643419670
--- /dev/null
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -0,0 +1,385 @@
+import base64
+import io
+
+from PIL import Image
+
+from odoo.tools.float_utils import float_round
+
+from .test_actions_data import ActionsDataCaseBase
+
+
+def fake_colored_image(color="#4169E1", size=(800, 500)):
+ with io.BytesIO() as img_file:
+ Image.new("RGB", size, color).save(img_file, "JPEG")
+ img_file.seek(0)
+ return base64.b64encode(img_file.read())
+
+
+class ActionsDataDetailCaseBase(ActionsDataCaseBase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.package = cls.move_a.move_line_ids.package_id
+ cls.lot = cls.env["stock.production.lot"].create(
+ {"product_id": cls.product_b.id, "company_id": cls.env.company.id}
+ )
+ cls.storage_type_pallet = cls.env.ref(
+ "stock_storage_type.package_storage_type_pallets"
+ )
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_actions() as work:
+ self.data = work.component(usage="data_detail")
+ with self.work_on_services() as work:
+ self.schema = work.component(usage="schema_detail")
+
+
+class ActionsDataDetailCase(ActionsDataDetailCaseBase):
+ def test_data_location(self):
+ location = self.stock_location
+ data = self.data.location_detail(location)
+ self.assert_schema(self.schema.location_detail(), data)
+ move_lines = self.env["stock.move.line"].search(
+ [
+ ("location_id", "=", location.id),
+ ("product_qty", ">", 0),
+ ("state", "not in", ("done", "cancel")),
+ ]
+ )
+ expected = {
+ "id": location.id,
+ "name": location.name,
+ "complete_name": location.complete_name,
+ "reserved_move_lines": self.data.move_lines(move_lines),
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_data_packaging(self):
+ data = self.data.packaging(self.packaging)
+ self.assert_schema(self.schema.packaging(), data)
+ expected = {"id": self.packaging.id, "name": self.packaging.name}
+ self.assertDictEqual(data, expected)
+
+ def test_data_lot(self):
+ lot = self.env["stock.production.lot"].create(
+ {
+ "product_id": self.product_b.id,
+ "company_id": self.env.company.id,
+ "ref": "#FOO",
+ "removal_date": "2020-05-20",
+ "life_date": "2020-05-31",
+ }
+ )
+ data = self.data.lot_detail(lot)
+ self.assert_schema(self.schema.lot_detail(), data)
+
+ expected = {
+ "id": lot.id,
+ "name": lot.name,
+ "ref": "#FOO",
+ "product": self.data.product_detail(self.product_b),
+ }
+ # ignore time and TZ, we don't care here
+ self.assertEqual(data.pop("removal_date").split("T")[0], "2020-05-20")
+ self.assertEqual(data.pop("expire_date").split("T")[0], "2020-05-31")
+ self.assertDictEqual(data, expected)
+
+ def test_data_package(self):
+ package = self.move_a.move_line_ids.package_id
+ package.product_packaging_id = self.packaging.id
+ package.package_storage_type_id = self.storage_type_pallet
+ # package.invalidate_cache()
+ data = self.data.package_detail(package, picking=self.picking)
+ self.assert_schema(self.schema.package_detail(), data)
+
+ lines = self.env["stock.move.line"].search(
+ [("package_id", "=", package.id), ("state", "not in", ("done", "cancel"))]
+ )
+ pickings = lines.mapped("picking_id")
+ expected = {
+ "id": package.id,
+ "name": package.name,
+ "move_line_count": 1,
+ "packaging": self.data.packaging(package.product_packaging_id),
+ "weight": 0,
+ "pickings": self.data.pickings(pickings),
+ "lines": self.data.move_lines(lines),
+ "storage_type": {
+ "id": self.storage_type_pallet.id,
+ "name": self.storage_type_pallet.name,
+ },
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_data_picking(self):
+ picking = self.picking
+ carrier = picking.carrier_id.search([])[0]
+ picking.write(
+ {
+ "origin": "created by test",
+ "note": "read me",
+ "priority": "3",
+ "carrier_id": carrier.id,
+ }
+ )
+ picking.move_lines.write({"date_expected": "2020-05-13"})
+ data = self.data.picking_detail(picking)
+ self.assert_schema(self.schema.picking_detail(), data)
+ expected = {
+ "id": picking.id,
+ "move_line_count": 4,
+ "name": picking.name,
+ "note": "read me",
+ "origin": "created by test",
+ "weight": 110.0,
+ "partner": {"id": self.customer.id, "name": self.customer.name},
+ "priority": "Very Urgent",
+ "operation_type": {
+ "id": picking.picking_type_id.id,
+ "name": picking.picking_type_id.name,
+ },
+ "carrier": {"id": carrier.id, "name": carrier.name},
+ "lines": self.data.move_lines(picking.move_line_ids),
+ }
+ self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13")
+ self.maxDiff = None
+ self.assertDictEqual(data, expected)
+
+ def test_data_move_line_package(self):
+ move_line = self.move_a.move_line_ids
+ result_package = self.env["stock.quant.package"].create(
+ {"product_packaging_id": self.packaging.id}
+ )
+ move_line.write({"qty_done": 3.0, "result_package_id": result_package.id})
+ data = self.data.move_line(move_line)
+ self.assert_schema(self.schema.move_line(), data)
+ product = self.product_a.with_context(location=move_line.location_id.id)
+ qty_available = product.qty_available
+ qty_reserved = float_round(
+ product.qty_available - product.free_qty,
+ precision_rounding=product.uom_id.rounding,
+ )
+ expected = {
+ "id": move_line.id,
+ "qty_done": 3.0,
+ "quantity": move_line.product_uom_qty,
+ "product": {
+ "id": self.product_a.id,
+ "name": "Product A",
+ "display_name": "[A] Product A",
+ "default_code": "A",
+ "qty_available": qty_available,
+ "qty_reserved": qty_reserved,
+ },
+ "lot": None,
+ "package_src": {
+ "id": move_line.package_id.id,
+ "name": move_line.package_id.name,
+ "move_line_count": 1,
+ "packaging": None,
+ "weight": 0.0,
+ },
+ "package_dest": {
+ "id": result_package.id,
+ "name": result_package.name,
+ "move_line_count": 0,
+ "packaging": self.data.packaging(self.packaging),
+ "weight": 0.0,
+ },
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "location_dest": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_data_move_line_lot(self):
+ move_line = self.move_b.move_line_ids
+ data = self.data.move_line(move_line)
+ self.assert_schema(self.schema.move_line(), data)
+ product = self.product_b.with_context(location=move_line.location_id.id)
+ qty_available = product.qty_available
+ qty_reserved = float_round(
+ product.qty_available - product.free_qty,
+ precision_rounding=product.uom_id.rounding,
+ )
+ expected = {
+ "id": move_line.id,
+ "qty_done": 0.0,
+ "quantity": move_line.product_uom_qty,
+ "product": {
+ "id": self.product_b.id,
+ "name": "Product B",
+ "display_name": "[B] Product B",
+ "default_code": "B",
+ "qty_available": qty_available,
+ "qty_reserved": qty_reserved,
+ },
+ "lot": {
+ "id": move_line.lot_id.id,
+ "name": move_line.lot_id.name,
+ "ref": None,
+ },
+ "package_src": None,
+ "package_dest": None,
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "location_dest": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_data_move_line_package_lot(self):
+ self.maxDiff = None
+ move_line = self.move_c.move_line_ids
+ data = self.data.move_line(move_line)
+ self.assert_schema(self.schema.move_line(), data)
+ product = self.product_c.with_context(location=move_line.location_id.id)
+ qty_available = product.qty_available
+ qty_reserved = float_round(
+ product.qty_available - product.free_qty,
+ precision_rounding=product.uom_id.rounding,
+ )
+ expected = {
+ "id": move_line.id,
+ "qty_done": 0.0,
+ "quantity": move_line.product_uom_qty,
+ "product": {
+ "id": self.product_c.id,
+ "name": "Product C",
+ "display_name": "[C] Product C",
+ "default_code": "C",
+ "qty_available": qty_available,
+ "qty_reserved": qty_reserved,
+ },
+ "lot": {
+ "id": move_line.lot_id.id,
+ "name": move_line.lot_id.name,
+ "ref": None,
+ },
+ "package_src": {
+ "id": move_line.package_id.id,
+ "name": move_line.package_id.name,
+ "move_line_count": 1,
+ "packaging": None,
+ "weight": 0.0,
+ },
+ "package_dest": {
+ "id": move_line.result_package_id.id,
+ "name": move_line.result_package_id.name,
+ "move_line_count": 1,
+ "packaging": None,
+ "weight": 0.0,
+ },
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "location_dest": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_data_move_line_raw(self):
+ move_line = self.move_d.move_line_ids
+ data = self.data.move_line(move_line)
+ self.assert_schema(self.schema.move_line(), data)
+ product = self.product_d.with_context(location=move_line.location_id.id)
+ qty_available = product.qty_available
+ qty_reserved = float_round(
+ product.qty_available - product.free_qty,
+ precision_rounding=product.uom_id.rounding,
+ )
+ expected = {
+ "id": move_line.id,
+ "qty_done": 0.0,
+ "quantity": move_line.product_uom_qty,
+ "product": {
+ "id": self.product_d.id,
+ "name": "Product D",
+ "display_name": "[D] Product D",
+ "default_code": "D",
+ "qty_available": qty_available,
+ "qty_reserved": qty_reserved,
+ },
+ "lot": None,
+ "package_src": None,
+ "package_dest": None,
+ "location_src": {
+ "id": move_line.location_id.id,
+ "name": move_line.location_id.name,
+ },
+ "location_dest": {
+ "id": move_line.location_dest_id.id,
+ "name": move_line.location_dest_id.name,
+ },
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_product(self):
+ move_line = self.move_b.move_line_ids
+ product = move_line.product_id.with_context(location=move_line.location_id.id)
+ manuf = self.env["res.partner"].create({"name": "Manuf 1"})
+ product.write(
+ {
+ "image_128": fake_colored_image(size=(128, 128)),
+ "manufacturer": manuf.id,
+ }
+ )
+ vendor_a = self.env["res.partner"].create({"name": "Supplier A"})
+ vendor_b = self.env["res.partner"].create({"name": "Supplier B"})
+ self.env["product.supplierinfo"].create(
+ {
+ "name": vendor_a.id,
+ "product_tmpl_id": product.product_tmpl_id.id,
+ "product_id": product.id,
+ "product_code": "SUPP1",
+ }
+ )
+ self.env["product.supplierinfo"].create(
+ {
+ "name": vendor_b.id,
+ "product_tmpl_id": product.product_tmpl_id.id,
+ "product_id": product.id,
+ "product_code": "SUPP2",
+ }
+ )
+ data = self.data.product_detail(product)
+ self.assert_schema(self.schema.product_detail(), data)
+ qty_available = product.qty_available
+ qty_reserved = float_round(
+ product.qty_available - product.free_qty,
+ precision_rounding=product.uom_id.rounding,
+ )
+ expected = {
+ "id": product.id,
+ "name": "Product B",
+ "display_name": "[B] Product B",
+ "default_code": "B",
+ "qty_available": qty_available,
+ "qty_reserved": qty_reserved,
+ "image": "/web/image/product.product/{}/image_128".format(product.id),
+ "manufacturer": {"id": manuf.id, "name": manuf.name},
+ "suppliers": [
+ {
+ "id": v.name.id,
+ "name": v.name.name,
+ "product_name": None,
+ "product_code": v.product_code,
+ }
+ for v in product.seller_ids
+ ],
+ }
+ self.maxDiff = None
+ self.assertDictEqual(data, expected)
diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py
index 782b73cad6..c3af31cab5 100644
--- a/shopfloor/tests/test_scan_anything.py
+++ b/shopfloor/tests/test_scan_anything.py
@@ -1,19 +1,9 @@
-from .test_actions_data import ActionsDataCaseBase
+from .test_actions_data_detail import ActionsDataDetailCaseBase
-class ScanAnythingCase(ActionsDataCaseBase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.package = cls.move_a.move_line_ids.package_id
- cls.lot = cls.env["stock.production.lot"].create(
- {"product_id": cls.product_b.id, "company_id": cls.env.company.id}
- )
-
+class ScanAnythingCase(ActionsDataDetailCaseBase):
def setUp(self):
super().setUp()
- with self.work_on_actions() as work:
- self.data = work.component(usage="data")
with self.work_on_services() as work:
self.service = work.component(usage="scan_anything")
@@ -39,35 +29,35 @@ def test_scan_product(self):
record.barcode = "PROD-B"
rec_type = "product"
identifier = record.barcode
- data = self.data.product(record)
+ data = self.data.product_detail(record)
self._test_response_ok(rec_type, data, identifier)
def test_scan_location(self):
record = self.stock_location
rec_type = "location"
identifier = record.barcode
- data = self.data.location(record)
+ data = self.data.location_detail(record)
self._test_response_ok(rec_type, data, identifier)
def test_scan_package(self):
record = self.package
rec_type = "package"
identifier = record.name
- data = self.data.package(record)
+ data = self.data.package_detail(record)
self._test_response_ok(rec_type, data, identifier)
def test_scan_lot(self):
record = self.lot
rec_type = "lot"
identifier = record.name
- data = self.data.lot(record)
+ data = self.data.lot_detail(record)
self._test_response_ok(rec_type, data, identifier)
def test_scan_transfer(self):
record = self.picking
rec_type = "transfer"
identifier = record.name
- data = self.data.picking(record)
+ data = self.data.picking_detail(record)
self._test_response_ok(rec_type, data, identifier)
def test_scan_error(self):
From 21fea246b375366c852fe30b4b68b7d0106b600a Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 13 May 2020 11:22:45 +0200
Subject: [PATCH 207/940] backend: location and product must expose barcode
---
shopfloor/actions/data.py | 4 +-
shopfloor/services/schema.py | 2 +
shopfloor/tests/common.py | 5 +
shopfloor/tests/test_actions_data.py | 90 ++++------
shopfloor/tests/test_actions_data_detail.py | 178 +++++++-------------
5 files changed, 103 insertions(+), 176 deletions(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 578f2ab5d7..977550d2ac 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -38,7 +38,7 @@ def locations(self, record, **kw):
@property
def _location_parser(self):
- return ["id", "name"]
+ return ["id", "name", "barcode"]
def picking(self, record, **kw):
return self._jsonify(record, self._picking_parser, **kw)
@@ -143,7 +143,7 @@ def products(self, record, **kw):
@property
def _product_parser(self):
- return ["id", "name", "display_name", "default_code"]
+ return ["id", "name", "display_name", "default_code", "barcode"]
def picking_batch(self, record, with_pickings=True, **kw):
parser = self._picking_batch_parser
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 4bdd4a9fda..224db160ad 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -75,6 +75,7 @@ def product(self):
"name": {"type": "string", "nullable": False, "required": True},
"display_name": {"type": "string", "nullable": False, "required": True},
"default_code": {"type": "string", "nullable": False, "required": True},
+ "barcode": {"type": "string", "nullable": True, "required": False},
}
def package(self):
@@ -102,6 +103,7 @@ def location(self):
return {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
+ "barcode": {"type": "string", "nullable": True, "required": False},
}
def packaging(self):
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index e1483551ef..f4ba02ca4c 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -71,12 +71,17 @@ def setUpClassVars(cls):
stock_location = cls.env.ref("stock.stock_location_stock")
cls.stock_location = stock_location
cls.customer_location = cls.env.ref("stock.stock_location_customers")
+ cls.customer_location.barcode = "CUSTOMERS"
cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone")
cls.dispatch_location.barcode = "DISPATCH"
cls.packing_location = cls.env.ref("stock.location_pack_zone")
+ cls.packing_location.barcode = "PACKING"
cls.input_location = cls.env.ref("stock.stock_location_company")
+ cls.input_location.barcode = "INPUT"
cls.shelf1 = cls.env.ref("stock.stock_location_components")
+ cls.shelf1.barcode = "SHELF1"
cls.shelf2 = cls.env.ref("stock.stock_location_14")
+ cls.shelf2.barcode = "SHELF2"
cls.customer = cls.env["res.partner"].create({"name": "Customer"})
@classmethod
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index ca3678d3cb..b25c08cd0b 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -46,6 +46,22 @@ def assert_schema(self, schema, data):
validator = Validator(schema)
self.assertTrue(validator.validate(data), validator.errors)
+ def _expected_location(self, record, **kw):
+ return {
+ "id": record.id,
+ "name": record.name,
+ "barcode": record.barcode,
+ }
+
+ def _expected_product(self, record, **kw):
+ return {
+ "id": record.id,
+ "name": record.name,
+ "display_name": record.display_name,
+ "default_code": record.default_code,
+ "barcode": record.barcode,
+ }
+
class ActionsDataCase(ActionsDataCaseBase):
def setUp(self):
@@ -65,7 +81,11 @@ def test_data_location(self):
location = self.stock_location
data = self.data.location(location)
self.assert_schema(self.schema.location(), data)
- expected = {"id": location.id, "name": location.name}
+ expected = {
+ "id": location.id,
+ "name": location.name,
+ "barcode": location.barcode,
+ }
self.assertDictEqual(data, expected)
def test_data_lot(self):
@@ -122,12 +142,7 @@ def test_data_move_line_package(self):
"id": move_line.id,
"qty_done": 3.0,
"quantity": move_line.product_uom_qty,
- "product": {
- "id": self.product_a.id,
- "name": "Product A",
- "display_name": "[A] Product A",
- "default_code": "A",
- },
+ "product": self._expected_product(self.product_a),
"lot": None,
"package_src": {
"id": move_line.package_id.id,
@@ -145,14 +160,8 @@ def test_data_move_line_package(self):
# TODO
"weight": 0,
},
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "location_dest": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
+ "location_src": self._expected_location(move_line.location_id),
+ "location_dest": self._expected_location(move_line.location_dest_id),
}
self.assertDictEqual(data, expected)
@@ -164,12 +173,7 @@ def test_data_move_line_lot(self):
"id": move_line.id,
"qty_done": 0.0,
"quantity": move_line.product_uom_qty,
- "product": {
- "id": self.product_b.id,
- "name": "Product B",
- "display_name": "[B] Product B",
- "default_code": "B",
- },
+ "product": self._expected_product(self.product_b),
"lot": {
"id": move_line.lot_id.id,
"name": move_line.lot_id.name,
@@ -177,14 +181,8 @@ def test_data_move_line_lot(self):
},
"package_src": None,
"package_dest": None,
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "location_dest": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
+ "location_src": self._expected_location(move_line.location_id),
+ "location_dest": self._expected_location(move_line.location_dest_id),
}
self.assertDictEqual(data, expected)
@@ -197,12 +195,7 @@ def test_data_move_line_package_lot(self):
"id": move_line.id,
"qty_done": 0.0,
"quantity": move_line.product_uom_qty,
- "product": {
- "id": self.product_c.id,
- "name": "Product C",
- "display_name": "[C] Product C",
- "default_code": "C",
- },
+ "product": self._expected_product(self.product_c),
"lot": {
"id": move_line.lot_id.id,
"name": move_line.lot_id.name,
@@ -224,14 +217,8 @@ def test_data_move_line_package_lot(self):
# TODO
"weight": 0,
},
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "location_dest": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
+ "location_src": self._expected_location(move_line.location_id),
+ "location_dest": self._expected_location(move_line.location_dest_id),
}
self.assertDictEqual(data, expected)
@@ -243,22 +230,11 @@ def test_data_move_line_raw(self):
"id": move_line.id,
"qty_done": 0.0,
"quantity": move_line.product_uom_qty,
- "product": {
- "id": self.product_d.id,
- "name": "Product D",
- "display_name": "[D] Product D",
- "default_code": "D",
- },
+ "product": self._expected_product(self.product_d),
"lot": None,
"package_src": None,
"package_dest": None,
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "location_dest": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
+ "location_src": self._expected_location(move_line.location_id),
+ "location_dest": self._expected_location(move_line.location_dest_id),
}
self.assertDictEqual(data, expected)
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index f643419670..def85a49d1 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -34,6 +34,50 @@ def setUp(self):
with self.work_on_services() as work:
self.schema = work.component(usage="schema_detail")
+ def _expected_location_detail(self, record, **kw):
+ return dict(
+ **self._expected_location(record),
+ **{
+ "complete_name": record.complete_name,
+ "reserved_move_lines": self.data.move_lines(kw.get("move_lines", [])),
+ }
+ )
+
+ def _expected_product_detail(self, record, **kw):
+ qty_available = record.qty_available
+ qty_reserved = float_round(
+ record.qty_available - record.free_qty,
+ precision_rounding=record.uom_id.rounding,
+ )
+ detail = {
+ "qty_available": qty_available,
+ "qty_reserved": qty_reserved,
+ }
+ if kw.get("full"):
+ detail.update(
+ {
+ "image": "/web/image/product.product/{}/image_128".format(record.id)
+ if record.image_128
+ else None,
+ "manufacturer": {
+ "id": record.manufacturer.id,
+ "name": record.manufacturer.name,
+ }
+ if record.manufacturer
+ else None,
+ "suppliers": [
+ {
+ "id": v.name.id,
+ "name": v.name.name,
+ "product_name": None,
+ "product_code": v.product_code,
+ }
+ for v in record.seller_ids
+ ],
+ }
+ )
+ return dict(**self._expected_product(record), **detail)
+
class ActionsDataDetailCase(ActionsDataDetailCaseBase):
def test_data_location(self):
@@ -47,13 +91,9 @@ def test_data_location(self):
("state", "not in", ("done", "cancel")),
]
)
- expected = {
- "id": location.id,
- "name": location.name,
- "complete_name": location.complete_name,
- "reserved_move_lines": self.data.move_lines(move_lines),
- }
- self.assertDictEqual(data, expected)
+ self.assertDictEqual(
+ data, self._expected_location_detail(location, move_lines=move_lines)
+ )
def test_data_packaging(self):
data = self.data.packaging(self.packaging)
@@ -78,7 +118,7 @@ def test_data_lot(self):
"id": lot.id,
"name": lot.name,
"ref": "#FOO",
- "product": self.data.product_detail(self.product_b),
+ "product": self._expected_product_detail(self.product_b, full=True),
}
# ignore time and TZ, we don't care here
self.assertEqual(data.pop("removal_date").split("T")[0], "2020-05-20")
@@ -155,23 +195,11 @@ def test_data_move_line_package(self):
data = self.data.move_line(move_line)
self.assert_schema(self.schema.move_line(), data)
product = self.product_a.with_context(location=move_line.location_id.id)
- qty_available = product.qty_available
- qty_reserved = float_round(
- product.qty_available - product.free_qty,
- precision_rounding=product.uom_id.rounding,
- )
expected = {
"id": move_line.id,
"qty_done": 3.0,
"quantity": move_line.product_uom_qty,
- "product": {
- "id": self.product_a.id,
- "name": "Product A",
- "display_name": "[A] Product A",
- "default_code": "A",
- "qty_available": qty_available,
- "qty_reserved": qty_reserved,
- },
+ "product": self._expected_product_detail(product),
"lot": None,
"package_src": {
"id": move_line.package_id.id,
@@ -187,14 +215,8 @@ def test_data_move_line_package(self):
"packaging": self.data.packaging(self.packaging),
"weight": 0.0,
},
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "location_dest": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
+ "location_src": self._expected_location(move_line.location_id),
+ "location_dest": self._expected_location(move_line.location_dest_id),
}
self.assertDictEqual(data, expected)
@@ -203,23 +225,11 @@ def test_data_move_line_lot(self):
data = self.data.move_line(move_line)
self.assert_schema(self.schema.move_line(), data)
product = self.product_b.with_context(location=move_line.location_id.id)
- qty_available = product.qty_available
- qty_reserved = float_round(
- product.qty_available - product.free_qty,
- precision_rounding=product.uom_id.rounding,
- )
expected = {
"id": move_line.id,
"qty_done": 0.0,
"quantity": move_line.product_uom_qty,
- "product": {
- "id": self.product_b.id,
- "name": "Product B",
- "display_name": "[B] Product B",
- "default_code": "B",
- "qty_available": qty_available,
- "qty_reserved": qty_reserved,
- },
+ "product": self._expected_product_detail(product),
"lot": {
"id": move_line.lot_id.id,
"name": move_line.lot_id.name,
@@ -227,14 +237,8 @@ def test_data_move_line_lot(self):
},
"package_src": None,
"package_dest": None,
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "location_dest": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
+ "location_src": self._expected_location(move_line.location_id),
+ "location_dest": self._expected_location(move_line.location_dest_id),
}
self.assertDictEqual(data, expected)
@@ -244,23 +248,11 @@ def test_data_move_line_package_lot(self):
data = self.data.move_line(move_line)
self.assert_schema(self.schema.move_line(), data)
product = self.product_c.with_context(location=move_line.location_id.id)
- qty_available = product.qty_available
- qty_reserved = float_round(
- product.qty_available - product.free_qty,
- precision_rounding=product.uom_id.rounding,
- )
expected = {
"id": move_line.id,
"qty_done": 0.0,
"quantity": move_line.product_uom_qty,
- "product": {
- "id": self.product_c.id,
- "name": "Product C",
- "display_name": "[C] Product C",
- "default_code": "C",
- "qty_available": qty_available,
- "qty_reserved": qty_reserved,
- },
+ "product": self._expected_product_detail(product),
"lot": {
"id": move_line.lot_id.id,
"name": move_line.lot_id.name,
@@ -280,14 +272,8 @@ def test_data_move_line_package_lot(self):
"packaging": None,
"weight": 0.0,
},
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "location_dest": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
+ "location_src": self._expected_location(move_line.location_id),
+ "location_dest": self._expected_location(move_line.location_dest_id),
}
self.assertDictEqual(data, expected)
@@ -296,34 +282,16 @@ def test_data_move_line_raw(self):
data = self.data.move_line(move_line)
self.assert_schema(self.schema.move_line(), data)
product = self.product_d.with_context(location=move_line.location_id.id)
- qty_available = product.qty_available
- qty_reserved = float_round(
- product.qty_available - product.free_qty,
- precision_rounding=product.uom_id.rounding,
- )
expected = {
"id": move_line.id,
"qty_done": 0.0,
"quantity": move_line.product_uom_qty,
- "product": {
- "id": self.product_d.id,
- "name": "Product D",
- "display_name": "[D] Product D",
- "default_code": "D",
- "qty_available": qty_available,
- "qty_reserved": qty_reserved,
- },
+ "product": self._expected_product_detail(product),
"lot": None,
"package_src": None,
"package_dest": None,
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "location_dest": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
+ "location_src": self._expected_location(move_line.location_id),
+ "location_dest": self._expected_location(move_line.location_dest_id),
}
self.assertDictEqual(data, expected)
@@ -357,29 +325,5 @@ def test_product(self):
)
data = self.data.product_detail(product)
self.assert_schema(self.schema.product_detail(), data)
- qty_available = product.qty_available
- qty_reserved = float_round(
- product.qty_available - product.free_qty,
- precision_rounding=product.uom_id.rounding,
- )
- expected = {
- "id": product.id,
- "name": "Product B",
- "display_name": "[B] Product B",
- "default_code": "B",
- "qty_available": qty_available,
- "qty_reserved": qty_reserved,
- "image": "/web/image/product.product/{}/image_128".format(product.id),
- "manufacturer": {"id": manuf.id, "name": manuf.name},
- "suppliers": [
- {
- "id": v.name.id,
- "name": v.name.name,
- "product_name": None,
- "product_code": v.product_code,
- }
- for v in product.seller_ids
- ],
- }
- self.maxDiff = None
+ expected = self._expected_product_detail(product, full=True)
self.assertDictEqual(data, expected)
From 729f23c46db5f33f4a5c4e49f3618815949ea8bb Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 13 May 2020 12:03:39 +0200
Subject: [PATCH 208/940] backend: package detail move_lines explicit
---
shopfloor/actions/data_detail.py | 2 +-
shopfloor/services/schema_detail.py | 2 +-
shopfloor/tests/test_actions_data_detail.py | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py
index bb260db544..2cd588e7f6 100644
--- a/shopfloor/actions/data_detail.py
+++ b/shopfloor/actions/data_detail.py
@@ -68,7 +68,7 @@ def _package_detail_parser(self):
lambda record, fname: self.pickings(record[fname].mapped("picking_id")),
),
(
- "reserved_move_line_ids:lines",
+ "reserved_move_line_ids:move_lines",
lambda record, fname: self.move_lines(record[fname]),
),
("package_storage_type_id:storage_type", ["id", "name"]),
diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py
index 435b108d5e..b28f069e7e 100644
--- a/shopfloor/services/schema_detail.py
+++ b/shopfloor/services/schema_detail.py
@@ -73,7 +73,7 @@ def package_detail(self):
{
"pickings": self._schema_list_of(self.picking()),
"storage_type": self._schema_dict_of(self._simple_record()),
- "lines": self._schema_list_of(self.move_line()),
+ "move_lines": self._schema_list_of(self.move_line()),
}
)
return schema
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index def85a49d1..cbd2e71bcd 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -144,7 +144,7 @@ def test_data_package(self):
"packaging": self.data.packaging(package.product_packaging_id),
"weight": 0,
"pickings": self.data.pickings(pickings),
- "lines": self.data.move_lines(lines),
+ "move_lines": self.data.move_lines(lines),
"storage_type": {
"id": self.storage_type_pallet.id,
"name": self.storage_type_pallet.name,
From fd19bdbd813a47b7f0959e14c25d692ae00f1eb2 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 15 May 2020 08:00:11 +0200
Subject: [PATCH 209/940] Improve method verifying if location is a sublocation
We don't need to do a query as we can find it using the parent path
stored on the records.
---
shopfloor/models/stock_location.py | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
index cb3e8b8856..a1f1614a87 100644
--- a/shopfloor/models/stock_location.py
+++ b/shopfloor/models/stock_location.py
@@ -14,11 +14,9 @@ class StockLocation(models.Model):
def is_sublocation_of(self, others):
"""Return True if self is a sublocation of at least one other"""
self.ensure_one()
- return bool(
- self.env["stock.location"].search_count(
- [("id", "child_of", others.ids), ("id", "=", self.id)]
- )
- )
+ # Efficient way to verify that the current location is
+ # below one of the other location without using SQL.
+ return any(self.parent_path.startswith(other.parent_path) for other in others)
def _get_reserved_move_lines(self):
return self.env["stock.move.line"].search(
From d80072cb0ea9c521fff2a7c8b1e45757400bbde3 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 15 May 2020 13:03:49 +0200
Subject: [PATCH 210/940] backend: rename message.message to message.body
---
shopfloor/actions/message.py | 72 +++++++++----------
shopfloor/services/checkout.py | 36 ++++------
shopfloor/services/cluster_picking.py | 12 ++--
shopfloor/services/scan_anything.py | 2 +-
shopfloor/services/single_pack_putaway.py | 2 +-
shopfloor/services/validator.py | 2 +-
shopfloor/tests/common.py | 2 +
shopfloor/tests/test_checkout_cancel_line.py | 4 +-
.../tests/test_checkout_change_packaging.py | 8 +--
shopfloor/tests/test_checkout_done.py | 10 +--
shopfloor/tests/test_checkout_list_package.py | 11 ++-
shopfloor/tests/test_checkout_new_package.py | 2 +-
shopfloor/tests/test_checkout_no_package.py | 2 +-
shopfloor/tests/test_checkout_scan.py | 10 +--
shopfloor/tests/test_checkout_scan_line.py | 18 ++---
.../test_checkout_scan_package_action.py | 12 ++--
shopfloor/tests/test_checkout_select.py | 2 +-
shopfloor/tests/test_checkout_select_line.py | 4 +-
shopfloor/tests/test_checkout_set_qty.py | 10 +--
shopfloor/tests/test_cluster_picking_scan.py | 32 ++++-----
.../tests/test_cluster_picking_select.py | 10 +--
.../tests/test_cluster_picking_unload.py | 16 ++---
shopfloor/tests/test_scan_anything.py | 4 +-
shopfloor/tests/test_single_pack_putaway.py | 31 ++++----
shopfloor/tests/test_single_pack_transfer.py | 34 +++++----
25 files changed, 164 insertions(+), 184 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index f7c56f8552..7a6f099c84 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -20,28 +20,28 @@ class MessageAction(Component):
def no_picking_type(self):
return {
"message_type": "error",
- "message": _("No operation type found for this menu and profile."),
+ "body": _("No operation type found for this menu and profile."),
}
def several_picking_types(self):
return {
"message_type": "error",
- "message": _("Several operation types found for this menu and profile."),
+ "body": _("Several operation types found for this menu and profile."),
}
def package_not_found_for_barcode(self, barcode):
return {
"message_type": "error",
- "message": _("The package %s doesn't exist") % barcode,
+ "body": _("The package %s doesn't exist") % barcode,
}
def bin_not_found_for_barcode(self, barcode):
- return {"message_type": "error", "message": _("Bin %s doesn't exist") % barcode}
+ return {"message_type": "error", "body": _("Bin %s doesn't exist") % barcode}
def package_not_allowed_in_src_location(self, barcode, picking_types):
return {
"message_type": "error",
- "message": _("You cannot work on a package (%s) outside of locations: %s")
+ "body": _("You cannot work on a package (%s) outside of locations: %s")
% (
barcode,
", ".join(picking_types.mapped("default_location_src_id.name")),
@@ -51,94 +51,92 @@ def package_not_allowed_in_src_location(self, barcode, picking_types):
def already_running_ask_confirmation(self):
return {
"message_type": "warning",
- "message": _(
- "Operation's already running. Would you like to take it over?"
- ),
+ "body": _("Operation's already running. Would you like to take it over?"),
}
def scan_destination(self):
- return {"message_type": "info", "message": _("Scan the destination location")}
+ return {"message_type": "info", "body": _("Scan the destination location")}
def scan_lot_on_product_tracked_by_lot(self):
return {
"message_type": "warning",
- "message": _("Product tracked by lot, please scan one."),
+ "body": _("Product tracked by lot, please scan one."),
}
def operation_not_found(self):
return {
"message_type": "error",
- "message": _("This operation does not exist anymore."),
+ "body": _("This operation does not exist anymore."),
}
def stock_picking_not_found(self):
return {
"message_type": "error",
- "message": _("This transfer does not exist anymore."),
+ "body": _("This transfer does not exist anymore."),
}
def record_not_found(self):
return {
"message_type": "error",
- "message": _("The record you were working on does not exist anymore."),
+ "body": _("The record you were working on does not exist anymore."),
}
def barcode_not_found(self):
- return {"message_type": "error", "message": _("Barcode not found")}
+ return {"message_type": "error", "body": _("Barcode not found")}
def operation_has_been_canceled_elsewhere(self):
return {
"message_type": "warning",
- "message": _("Restart the operation, someone has canceled it."),
+ "body": _("Restart the operation, someone has canceled it."),
}
def no_location_found(self):
return {
"message_type": "error",
- "message": _("No location found for this barcode."),
+ "body": _("No location found for this barcode."),
}
def location_not_allowed(self):
- return {"message_type": "error", "message": _("Location not allowed here.")}
+ return {"message_type": "error", "body": _("Location not allowed here.")}
def dest_location_not_allowed(self):
- return {"message_type": "error", "message": _("You cannot place it here")}
+ return {"message_type": "error", "body": _("You cannot place it here")}
def need_confirmation(self):
- return {"message_type": "warning", "message": _("Are you sure?")}
+ return {"message_type": "warning", "body": _("Are you sure?")}
def confirm_location_changed(self, from_location, to_location):
return {
"message_type": "warning",
- "message": _("Confirm location change from %s to %s?")
+ "body": _("Confirm location change from %s to %s?")
% (from_location.name, to_location.name),
}
def confirm_pack_moved(self):
return {
"message_type": "success",
- "message": _("The pack has been moved, you can scan a new pack."),
+ "body": _("The pack has been moved, you can scan a new pack."),
}
def already_done(self):
- return {"message_type": "info", "message": _("Operation already processed.")}
+ return {"message_type": "info", "body": _("Operation already processed.")}
def confirm_canceled_scan_next_pack(self):
return {
"message_type": "info",
- "message": _("Canceled, you can scan a new pack."),
+ "body": _("Canceled, you can scan a new pack."),
}
def no_pack_in_location(self, location):
return {
"message_type": "error",
- "message": _("Location %s doesn't contain any package." % location.name),
+ "body": _("Location %s doesn't contain any package." % location.name),
}
def several_packs_in_location(self, location):
return {
"message_type": "warning",
- "message": _(
+ "body": _(
"Several packages found in %s, please scan a package." % location.name
),
}
@@ -146,15 +144,13 @@ def several_packs_in_location(self, location):
def several_lots_in_location(self, location):
return {
"message_type": "warning",
- "message": _(
- "Several lots found in %s, please scan a lot." % location.name
- ),
+ "body": _("Several lots found in %s, please scan a lot." % location.name),
}
def several_products_in_location(self, location):
return {
"message_type": "warning",
- "message": _(
+ "body": _(
"Several products found in %s, please scan a product." % location.name
),
}
@@ -162,19 +158,19 @@ def several_products_in_location(self, location):
def no_pending_operation_for_pack(self, pack):
return {
"message_type": "error",
- "message": _("No pending operation for package %s." % pack.name),
+ "body": _("No pending operation for package %s." % pack.name),
}
def unrecoverable_error(self):
return {
"message_type": "error",
- "message": _("Unrecoverable error, please restart."),
+ "body": _("Unrecoverable error, please restart."),
}
def x_units_put_in_package(self, qty, product, package):
return {
"message_type": "success",
- "message": _("{} {} put in {}").format(
+ "body": _("{} {} put in {}").format(
qty, product.display_name, package.name
),
}
@@ -182,19 +178,19 @@ def x_units_put_in_package(self, qty, product, package):
def cannot_move_something_in_picking_type(self):
return {
"message_type": "error",
- "message": _("You cannot move this using this menu."),
+ "body": _("You cannot move this using this menu."),
}
def stock_picking_not_available(self, picking):
return {
"message_type": "error",
- "message": _("Transfer {} is not available.").format(picking.name),
+ "body": _("Transfer {} is not available.").format(picking.name),
}
def product_multiple_packages_scan_package(self):
return {
"message_type": "warning",
- "message": _(
+ "body": _(
"This product is part of multiple packages, please scan a package."
),
}
@@ -202,13 +198,11 @@ def product_multiple_packages_scan_package(self):
def lot_multiple_packages_scan_package(self):
return {
"message_type": "warning",
- "message": _(
- "This lot is part of multiple packages, please scan a package."
- ),
+ "body": _("This lot is part of multiple packages, please scan a package."),
}
def batch_transfer_complete(self):
return {
"message_type": "success",
- "message": _("Batch Transfer complete"),
+ "body": _("Batch Transfer complete"),
}
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index e5afa78e24..536e2465a3 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -76,7 +76,7 @@ def scan_document(self, barcode):
return self._response_for_select_document(
message={
"message_type": "error",
- "message": _(
+ "body": _(
"Several transfers found, please scan a package"
" or select a transfer manually."
),
@@ -293,7 +293,7 @@ def _select_lines_from_package(self, picking, selection_lines, package):
picking,
message={
"message_type": "error",
- "message": _("Package {} is not in the current transfer.").format(
+ "body": _("Package {} is not in the current transfer.").format(
package.name
),
},
@@ -314,7 +314,7 @@ def _select_lines_from_product(self, picking, selection_lines, product):
picking,
message={
"message_type": "error",
- "message": _("Product is not in the current transfer."),
+ "body": _("Product is not in the current transfer."),
},
)
@@ -346,7 +346,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
picking,
message={
"message_type": "error",
- "message": _("Lot is not in the current transfer."),
+ "body": _("Lot is not in the current transfer."),
},
)
@@ -447,7 +447,7 @@ def _change_line_qty(
if qty_done > move_line.product_uom_qty:
qty_done = move_line.product_uom_qty
message = {
- "message": _(
+ "body": _(
"Not allowed to pack more than the quantity, "
"the value has been changed to the maximum."
),
@@ -455,7 +455,7 @@ def _change_line_qty(
}
if qty_done < 0:
message = {
- "message": _("Negative quantity not allowed."),
+ "body": _("Negative quantity not allowed."),
"message_type": "error",
}
else:
@@ -558,9 +558,7 @@ def _put_lines_in_package(self, picking, selected_lines, package):
selected_lines,
message={
"message_type": "error",
- "message": _("Not a valid destination package").format(
- package.name
- ),
+ "body": _("Not a valid destination package").format(package.name),
},
)
return self._put_lines_in_allowed_package(picking, selected_lines, package)
@@ -575,7 +573,7 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package):
picking,
message={
"message_type": "info",
- "message": _("Product(s) packed in {}").format(package.name),
+ "body": _("Product(s) packed in {}").format(package.name),
},
)
@@ -703,7 +701,7 @@ def no_package(self, picking_id, selected_line_ids):
picking,
message={
"message_type": "info",
- "message": _("Product(s) processed as raw product(s)"),
+ "body": _("Product(s) processed as raw product(s)"),
},
)
@@ -733,7 +731,7 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None):
move_lines,
message={
"message_type": "warning",
- "message": _("No valid package to select."),
+ "body": _("No valid package to select."),
},
)
data_struct = self.actions_for("data")
@@ -759,9 +757,7 @@ def _set_dest_package_from_selection(self, picking, selected_lines, package):
selected_lines,
message={
"message_type": "error",
- "message": _("Not a valid destination package").format(
- package.name
- ),
+ "body": _("Not a valid destination package").format(package.name),
},
)
return self._put_lines_in_allowed_package(picking, selected_lines, package)
@@ -883,7 +879,7 @@ def set_packaging(self, picking_id, package_id, packaging_id):
picking,
message={
"message_type": "success",
- "message": _("Packaging changed on package {}").format(package.name),
+ "body": _("Packaging changed on package {}").format(package.name),
},
)
@@ -955,7 +951,7 @@ def done(self, picking_id, confirmation=False):
need_confirm=True,
message={
"message_type": "warning",
- "message": _(
+ "body": _(
"Not all lines have been processed with full quantity. "
"Do you confirm partial operation?"
),
@@ -967,16 +963,14 @@ def done(self, picking_id, confirmation=False):
need_confirm=True,
message={
"message_type": "warning",
- "message": _(
- "Remaining raw product not packed, proceed anyway?"
- ),
+ "body": _("Remaining raw product not packed, proceed anyway?"),
},
)
picking.action_done()
return self._response_for_select_document(
message={
"message_type": "success",
- "message": _("Transfer {} done").format(picking.name),
+ "body": _("Transfer {} done").format(picking.name),
}
)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 849faa01da..edc7f6844e 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -181,9 +181,7 @@ def find_batch(self):
return self._response_for_start(
message={
"message_type": "info",
- "message": _(
- "No more work to do, please create a new batch transfer"
- ),
+ "body": _("No more work to do, please create a new batch transfer"),
},
)
@@ -285,7 +283,7 @@ def select(self, picking_batch_id):
base_response=self.list_batch(),
message={
"message_type": "warning",
- "message": _("This batch cannot be selected."),
+ "body": _("This batch cannot be selected."),
},
)
@@ -551,7 +549,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
move_line,
message={
"message_type": "error",
- "message": _("You must not pick more than {} units.").format(
+ "body": _("You must not pick more than {} units.").format(
move_line.product_uom_qty
),
},
@@ -581,7 +579,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
move_line,
message={
"message_type": "error",
- "message": _(
+ "body": _(
"The destination bin {} is not empty, please take another."
).format(bin_package.name),
},
@@ -925,7 +923,7 @@ def unload_scan_pack(self, picking_batch_id, package_id, barcode):
return self._response_for_unload_single(
batch,
package,
- message={"message_type": "error", "message": _("Wrong bin")},
+ message={"message_type": "error", "body": _("Wrong bin")},
)
return self._response_for_unload_set_destination(batch, package)
diff --git a/shopfloor/services/scan_anything.py b/shopfloor/services/scan_anything.py
index 9cfe8be26d..38371070fd 100644
--- a/shopfloor/services/scan_anything.py
+++ b/shopfloor/services/scan_anything.py
@@ -52,7 +52,7 @@ def _response_for_found(self, data):
def _response_for_not_found(self, tried):
message = {
- "message": _(
+ "body": _(
"Record not found.\n" "We've tried with the following types: {}"
).format(", ".join(tried)),
"message_type": "error",
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 3473b215ce..3345a2f48e 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -45,7 +45,7 @@ def _response_for_forbidden_start(self, existing_operations):
next_state="start",
message={
"message_type": "error",
- "message": _(
+ "body": _(
"An operation exists in %s %s. "
"You cannot process it with this shopfloor scenario."
)
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
index 353cfa0b10..dd9a2d1fb3 100644
--- a/shopfloor/services/validator.py
+++ b/shopfloor/services/validator.py
@@ -94,7 +94,7 @@ def _response_schema(self, data_schema=None, next_states=None):
"required": True,
"allowed": ["info", "warning", "error", "success"],
},
- "message": {"type": "string", "required": True},
+ "body": {"type": "string", "required": True},
},
},
"popup": {
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index f4ba02ca4c..966207b53f 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -31,6 +31,8 @@ class CommonCase(SavepointCase, ComponentMixin):
ANY = AnyObject()
+ maxDiff = None
+
@contextmanager
def work_on_services(self, **params):
params = params or {}
diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py
index 5f0589c73c..2dce5bca77 100644
--- a/shopfloor/tests/test_checkout_cancel_line.py
+++ b/shopfloor/tests/test_checkout_cancel_line.py
@@ -130,7 +130,7 @@ def test_cancel_line_error_package_not_found(self):
},
message={
"message_type": "error",
- "message": "The record you were working on does not exist anymore.",
+ "body": "The record you were working on does not exist anymore.",
},
)
@@ -148,6 +148,6 @@ def test_cancel_line_error_line_not_found(self):
},
message={
"message_type": "error",
- "message": "The record you were working on does not exist anymore.",
+ "body": "The record you were working on does not exist anymore.",
},
)
diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py
index ea1c4c6e74..e88d312a18 100644
--- a/shopfloor/tests/test_checkout_change_packaging.py
+++ b/shopfloor/tests/test_checkout_change_packaging.py
@@ -75,7 +75,7 @@ def test_list_packaging_error_package_not_found(self):
},
message={
"message_type": "error",
- "message": "The record you were working on does not exist anymore.",
+ "body": "The record you were working on does not exist anymore.",
},
)
@@ -100,7 +100,7 @@ def test_set_packaging_ok(self):
},
message={
"message_type": "success",
- "message": "Packaging changed on package {}".format(self.package.name),
+ "body": "Packaging changed on package {}".format(self.package.name),
},
)
@@ -122,7 +122,7 @@ def test_set_packaging_error_package_not_found(self):
},
message={
"message_type": "error",
- "message": "The record you were working on does not exist anymore.",
+ "body": "The record you were working on does not exist anymore.",
},
)
@@ -144,6 +144,6 @@ def test_set_packaging_error_packaging_not_found(self):
},
message={
"message_type": "error",
- "message": "The record you were working on does not exist anymore.",
+ "body": "The record you were working on does not exist anymore.",
},
)
diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py
index 2ddb794c7c..8af9442973 100644
--- a/shopfloor/tests/test_checkout_done.py
+++ b/shopfloor/tests/test_checkout_done.py
@@ -17,7 +17,7 @@ def test_done_ok(self):
next_state="select_document",
message={
"message_type": "success",
- "message": "Transfer {} done".format(picking.name),
+ "body": "Transfer {} done".format(picking.name),
},
)
@@ -48,7 +48,7 @@ def test_done_partial(self):
data={"picking": self._stock_picking_data(self.picking, done=True)},
message={
"message_type": "warning",
- "message": "Not all lines have been processed with full quantity. "
+ "body": "Not all lines have been processed with full quantity. "
"Do you confirm partial operation?",
},
)
@@ -67,7 +67,7 @@ def test_done_partial_confirm(self):
next_state="select_document",
message={
"message_type": "success",
- "message": "Transfer {} done".format(self.picking.name),
+ "body": "Transfer {} done".format(self.picking.name),
},
)
@@ -105,7 +105,7 @@ def test_done_partial(self):
data={"picking": self._stock_picking_data(self.picking, done=True)},
message={
"message_type": "warning",
- "message": "Remaining raw product not packed, proceed anyway?",
+ "body": "Remaining raw product not packed, proceed anyway?",
},
)
@@ -126,6 +126,6 @@ def test_done_partial_confirm(self):
next_state="select_document",
message={
"message_type": "success",
- "message": "Transfer {} done".format(self.picking.name),
+ "body": "Transfer {} done".format(self.picking.name),
},
)
diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py
index 78af3be96c..2055f2041a 100644
--- a/shopfloor/tests/test_checkout_list_package.py
+++ b/shopfloor/tests/test_checkout_list_package.py
@@ -84,10 +84,7 @@ def test_list_dest_package_error_no_package(self):
self._assert_selected_response(
response,
picking.move_line_ids,
- message={
- "message_type": "warning",
- "message": "No valid package to select.",
- },
+ message={"message_type": "warning", "body": "No valid package to select."},
)
@@ -148,7 +145,7 @@ def _assert_package_set(self, response):
data={"picking": self._stock_picking_data(self.picking)},
message={
"message_type": "info",
- "message": "Product(s) packed in {}".format(self.pack1.name),
+ "body": "Product(s) packed in {}".format(self.pack1.name),
},
)
@@ -194,7 +191,7 @@ def test_scan_dest_package_error_not_allowed(self):
self.allowed_packages,
message={
"message_type": "error",
- "message": "Not a valid destination package",
+ "body": "Not a valid destination package",
},
)
@@ -239,6 +236,6 @@ def test_set_dest_package_error_not_allowed(self):
self.allowed_packages,
message={
"message_type": "error",
- "message": "Not a valid destination package",
+ "body": "Not a valid destination package",
},
)
diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py
index ab720ce5ae..46403ac9cb 100644
--- a/shopfloor/tests/test_checkout_new_package.py
+++ b/shopfloor/tests/test_checkout_new_package.py
@@ -57,6 +57,6 @@ def test_new_package_ok(self):
data={"picking": self._stock_picking_data(picking)},
message={
"message_type": "info",
- "message": "Product(s) packed in {}".format(new_package.name),
+ "body": "Product(s) packed in {}".format(new_package.name),
},
)
diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py
index 2da2f732a6..052c8626c4 100644
--- a/shopfloor/tests/test_checkout_no_package.py
+++ b/shopfloor/tests/test_checkout_no_package.py
@@ -49,6 +49,6 @@ def test_no_package_ok(self):
data={"picking": self._stock_picking_data(picking)},
message={
"message_type": "info",
- "message": "Product(s) processed as raw product(s)",
+ "body": "Product(s) processed as raw product(s)",
},
)
diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py
index 43ba2cc6dd..672bfec403 100644
--- a/shopfloor/tests/test_checkout_scan.py
+++ b/shopfloor/tests/test_checkout_scan.py
@@ -28,7 +28,7 @@ def test_scan_document_error_not_found(self):
self.assert_response(
response,
next_state="select_document",
- message={"message_type": "error", "message": "Barcode not found"},
+ message={"message_type": "error", "body": "Barcode not found"},
)
def _test_scan_document_error_not_available(self, barcode_func):
@@ -49,7 +49,7 @@ def _test_scan_document_error_not_available(self, barcode_func):
next_state="select_document",
message={
"message_type": "error",
- "message": "Transfer {} is not available.".format(picking.name),
+ "body": "Transfer {} is not available.".format(picking.name),
},
)
@@ -77,7 +77,7 @@ def test_scan_document_error_location_not_child_of_type(self):
self.assert_response(
response,
next_state="select_document",
- message={"message_type": "error", "message": "Location not allowed here."},
+ message={"message_type": "error", "body": "Location not allowed here."},
)
def _test_scan_document_error_different_picking_type(self, barcode_func):
@@ -91,7 +91,7 @@ def _test_scan_document_error_different_picking_type(self, barcode_func):
next_state="select_document",
message={
"message_type": "error",
- "message": "You cannot move this using this menu.",
+ "body": "You cannot move this using this menu.",
},
)
@@ -122,7 +122,7 @@ def test_scan_document_error_location_several_pickings(self):
next_state="select_document",
message={
"message_type": "error",
- "message": "Several transfers found, please scan a package"
+ "body": "Several transfers found, please scan a package"
" or select a transfer manually.",
},
)
diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py
index 720bfb9973..e0063f515d 100644
--- a/shopfloor/tests/test_checkout_scan_line.py
+++ b/shopfloor/tests/test_checkout_scan_line.py
@@ -125,7 +125,7 @@ def test_scan_line_error_barcode_not_found(self):
self._test_scan_line_error(
picking,
"NOT A BARCODE",
- {"message_type": "error", "message": "Barcode not found"},
+ {"message_type": "error", "body": "Barcode not found"},
)
def test_scan_line_error_package_not_in_picking(self):
@@ -141,7 +141,7 @@ def test_scan_line_error_package_not_in_picking(self):
package.name,
{
"message_type": "error",
- "message": "Package {} is not in the current transfer.".format(
+ "body": "Package {} is not in the current transfer.".format(
package.name
),
},
@@ -159,7 +159,7 @@ def test_scan_line_error_product_tracked_by_lot(self):
self.product_a.barcode,
{
"message_type": "warning",
- "message": "Product tracked by lot, please scan one.",
+ "body": "Product tracked by lot, please scan one.",
},
)
@@ -177,7 +177,7 @@ def test_scan_line_error_product_in_two_packages(self):
self.product_a.barcode,
{
"message_type": "warning",
- "message": "This product is part of multiple"
+ "body": "This product is part of multiple"
" packages, please scan a package.",
},
)
@@ -198,7 +198,7 @@ def test_scan_line_error_product_in_one_package_and_unit(self):
self.product_a.barcode,
{
"message_type": "warning",
- "message": "This product is part of multiple"
+ "body": "This product is part of multiple"
" packages, please scan a package.",
},
)
@@ -212,7 +212,7 @@ def test_scan_line_error_product_not_in_picking(self):
self.product_b.barcode,
{
"message_type": "error",
- "message": "Product is not in the current transfer.",
+ "body": "Product is not in the current transfer.",
},
)
@@ -226,7 +226,7 @@ def test_scan_line_error_lot_not_in_picking(self):
self._test_scan_line_error(
picking,
lot.name,
- {"message_type": "error", "message": "Lot is not in the current transfer."},
+ {"message_type": "error", "body": "Lot is not in the current transfer."},
)
def test_scan_line_error_lot_in_two_packages(self):
@@ -247,7 +247,7 @@ def test_scan_line_error_lot_in_two_packages(self):
lot.name,
{
"message_type": "warning",
- "message": "This lot is part of multiple"
+ "body": "This lot is part of multiple"
" packages, please scan a package.",
},
)
@@ -270,7 +270,7 @@ def test_scan_line_error_lot_in_one_package_and_unit(self):
lot.name,
{
"message_type": "warning",
- "message": "This lot is part of multiple"
+ "body": "This lot is part of multiple"
" packages, please scan a package.",
},
)
diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py
index b9d5b870b1..dcc78ce607 100644
--- a/shopfloor/tests/test_checkout_scan_package_action.py
+++ b/shopfloor/tests/test_checkout_scan_package_action.py
@@ -99,7 +99,7 @@ def _test_scan_package_action_scan_product_error_tracked_by(
{move_line: origin_qty_done},
message={
"message_type": "warning",
- "message": "Product tracked by lot, please scan one.",
+ "body": "Product tracked by lot, please scan one.",
},
)
@@ -166,7 +166,7 @@ def test_scan_package_action_scan_package_keep_source_package_ok(self):
data={"picking": self._stock_picking_data(picking)},
message={
"message_type": "info",
- "message": "Product(s) packed in {}".format(pack1.name),
+ "body": "Product(s) packed in {}".format(pack1.name),
},
)
@@ -204,7 +204,7 @@ def test_scan_package_action_scan_package_error_invalid(self):
selected_line,
message={
"message_type": "error",
- "message": "Not a valid destination package",
+ "body": "Not a valid destination package",
},
)
@@ -265,7 +265,7 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self):
},
message={
"message_type": "info",
- "message": "Product(s) packed in {}".format(package.name),
+ "body": "Product(s) packed in {}".format(package.name),
},
)
@@ -350,7 +350,7 @@ def test_scan_package_action_scan_packaging_ok(self):
data={"picking": self._stock_picking_data(picking)},
message={
"message_type": "info",
- "message": "Product(s) packed in {}".format(new_package.name),
+ "body": "Product(s) packed in {}".format(new_package.name),
},
)
@@ -372,5 +372,5 @@ def test_scan_package_action_scan_not_found(self):
self._assert_selected_response(
response,
selected_line,
- message={"message_type": "error", "message": "Barcode not found"},
+ message={"message_type": "error", "body": "Barcode not found"},
)
diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py
index c8e30df13a..c22c47cb93 100644
--- a/shopfloor/tests/test_checkout_select.py
+++ b/shopfloor/tests/test_checkout_select.py
@@ -46,7 +46,7 @@ def _test_error(self, picking, msg):
self.assert_response(
response,
next_state="manual_selection",
- message={"message_type": "error", "message": msg},
+ message={"message_type": "error", "body": msg},
data={"pickings": [self._picking_summary_data(self.picking)]},
)
diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py
index c327ab989a..dede5bfd37 100644
--- a/shopfloor/tests/test_checkout_select_line.py
+++ b/shopfloor/tests/test_checkout_select_line.py
@@ -76,7 +76,7 @@ def test_select_line_package_error_not_found(self):
{"picking_id": self.picking.id, "package_id": selected_lines[0].id},
{
"message_type": "error",
- "message": "The record you were working on does not exist anymore.",
+ "body": "The record you were working on does not exist anymore.",
},
)
@@ -87,7 +87,7 @@ def test_select_line_move_line_error_not_found(self):
{"picking_id": self.picking.id, "move_line_id": selected_lines[0].id},
{
"message_type": "error",
- "message": "The record you were working on does not exist anymore.",
+ "body": "The record you were working on does not exist anymore.",
},
)
diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py
index d6a91c9028..39a6ee694f 100644
--- a/shopfloor/tests/test_checkout_set_qty.py
+++ b/shopfloor/tests/test_checkout_set_qty.py
@@ -68,7 +68,7 @@ def test_reset_line_qty_not_found(self):
selected_lines,
{line: line.product_uom_qty for line in selected_lines},
message={
- "message": "The record you were working on does not exist anymore.",
+ "body": "The record you were working on does not exist anymore.",
"message_type": "error",
},
)
@@ -116,7 +116,7 @@ def test_set_line_qty_not_found(self):
selected_lines,
{line: line.product_uom_qty for line in selected_lines},
message={
- "message": "The record you were working on does not exist anymore.",
+ "body": "The record you were working on does not exist anymore.",
"message_type": "error",
},
)
@@ -165,7 +165,7 @@ def test_set_custom_qty_not_found(self):
selected_lines,
{line: line.product_uom_qty for line in selected_lines},
message={
- "message": "The record you were working on does not exist anymore.",
+ "body": "The record you were working on does not exist anymore.",
"message_type": "error",
},
)
@@ -190,7 +190,7 @@ def test_set_custom_qty_above(self):
selected_lines,
{line1: line1.product_uom_qty, line2: line2.product_uom_qty},
message={
- "message": "Not allowed to pack more than the quantity, "
+ "body": "Not allowed to pack more than the quantity, "
"the value has been changed to the maximum.",
"message_type": "warning",
},
@@ -214,7 +214,7 @@ def test_set_custom_qty_negative(self):
selected_lines,
{line1: line1.product_uom_qty, line2: line2.product_uom_qty},
message={
- "message": "Negative quantity not allowed.",
+ "body": "Negative quantity not allowed.",
"message_type": "error",
},
)
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 181b9e0e15..bf37e8767c 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -69,7 +69,7 @@ def test_scan_line_error_product_tracked(self):
line.product_id.barcode,
{
"message_type": "warning",
- "message": "Product tracked by lot, please scan one.",
+ "body": "Product tracked by lot, please scan one.",
},
)
@@ -89,7 +89,7 @@ def test_scan_line_product_error_several_packages(self):
move.product_id.barcode,
{
"message_type": "warning",
- "message": "This product is part of multiple"
+ "body": "This product is part of multiple"
" packages, please scan a package.",
},
)
@@ -110,7 +110,7 @@ def test_scan_line_product_error_in_one_package_and_unit(self):
move.product_id.barcode,
{
"message_type": "warning",
- "message": "This product is part of multiple"
+ "body": "This product is part of multiple"
" packages, please scan a package.",
},
)
@@ -131,7 +131,7 @@ def test_scan_line_lot_error_several_packages(self):
line.lot_id.name,
{
"message_type": "warning",
- "message": "This lot is part of multiple"
+ "body": "This lot is part of multiple"
" packages, please scan a package.",
},
)
@@ -151,7 +151,7 @@ def test_scan_line_lot_error_in_one_package_and_unit(self):
line.lot_id.name,
{
"message_type": "warning",
- "message": "This lot is part of multiple"
+ "body": "This lot is part of multiple"
" packages, please scan a package.",
},
)
@@ -206,7 +206,7 @@ def test_scan_line_location_error_several_package(self):
location.barcode,
{
"message_type": "warning",
- "message": "Several packages found in Stock, please scan a package.",
+ "body": "Several packages found in Stock, please scan a package.",
},
)
@@ -225,7 +225,7 @@ def test_scan_line_location_error_several_products(self):
location.barcode,
{
"message_type": "warning",
- "message": "Several products found in Stock, please scan a product.",
+ "body": "Several products found in Stock, please scan a product.",
},
)
@@ -247,7 +247,7 @@ def test_scan_line_location_error_several_lots(self):
location.barcode,
{
"message_type": "warning",
- "message": "Several lots found in Stock, please scan a lot.",
+ "body": "Several lots found in Stock, please scan a lot.",
},
)
@@ -257,7 +257,7 @@ def test_scan_line_error_not_found(self):
self._scan_line_error(
self.batch.picking_ids.move_line_ids,
"NO_EXISTING_BARCODE",
- {"message_type": "error", "message": "Barcode not found"},
+ {"message_type": "error", "body": "Barcode not found"},
)
@@ -314,7 +314,7 @@ def test_scan_destination_pack_ok(self):
data=self._line_data(next_line),
message={
"message_type": "success",
- "message": "{} {} put in {}".format(
+ "body": "{} {} put in {}".format(
line.qty_done, line.product_id.display_name, self.bin1.name
),
},
@@ -402,7 +402,7 @@ def test_scan_destination_pack_not_empty_different_picking(self):
data=self._line_data(line),
message={
"message_type": "error",
- "message": "The destination bin {} is not empty,"
+ "body": "The destination bin {} is not empty,"
" please take another.".format(self.bin1.name),
},
)
@@ -425,7 +425,7 @@ def test_scan_destination_pack_bin_not_found(self):
data=self._line_data(line),
message={
"message_type": "error",
- "message": "Bin {} doesn't exist".format("⌿"),
+ "body": "Bin {} doesn't exist".format("⌿"),
},
)
@@ -446,7 +446,7 @@ def test_scan_destination_pack_quantity_more(self):
data=self._line_data(line),
message={
"message_type": "error",
- "message": "You must not pick more than {} units.".format(
+ "body": "You must not pick more than {} units.".format(
line.product_uom_qty
),
},
@@ -482,7 +482,7 @@ def test_scan_destination_pack_quantity_less(self):
data=self._line_data(new_line),
message={
"message_type": "success",
- "message": "{} {} put in {}".format(
+ "body": "{} {} put in {}".format(
line.qty_done, line.product_id.display_name, self.bin1.name
),
},
@@ -565,7 +565,7 @@ def test_is_zero_is_empty(self):
data=self._line_data(self.next_line),
message={
"message_type": "success",
- "message": "{} {} put in {}".format(
+ "body": "{} {} put in {}".format(
self.line.qty_done,
self.line.product_id.display_name,
self.bin1.name,
@@ -596,7 +596,7 @@ def test_is_zero_is_not_empty(self):
data=self._line_data(self.next_line),
message={
"message_type": "success",
- "message": "{} {} put in {}".format(
+ "body": "{} {} put in {}".format(
self.line.qty_done,
self.line.product_id.display_name,
self.bin1.name,
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index 00ef30dbb2..14c5285403 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -158,7 +158,7 @@ def test_find_batch_not_found(self):
next_state="start",
message={
"message_type": "info",
- "message": "No more work to do, please create a new batch transfer",
+ "body": "No more work to do, please create a new batch transfer",
},
)
@@ -285,7 +285,7 @@ def test_select_not_exists(self):
next_state="manual_selection",
message={
"message_type": "warning",
- "message": "This batch cannot be selected.",
+ "body": "This batch cannot be selected.",
},
data={"size": 0, "records": []},
)
@@ -305,7 +305,7 @@ def test_select_already_assigned(self):
next_state="manual_selection",
message={
"message_type": "warning",
- "message": "This batch cannot be selected.",
+ "body": "This batch cannot be selected.",
},
data={"size": 0, "records": []},
)
@@ -409,7 +409,7 @@ def test_confirm_start_not_exists(self):
response,
message={
"message_type": "error",
- "message": "The record you were working on does not exist anymore.",
+ "body": "The record you were working on does not exist anymore.",
},
next_state="start",
)
@@ -431,5 +431,5 @@ def test_confirm_start_all_is_done(self):
self.assert_response(
response,
next_state="start",
- message={"message": "Batch Transfer complete", "message_type": "success"},
+ message={"body": "Batch Transfer complete", "message_type": "success"},
)
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 337be573ae..1205ec10b9 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -157,7 +157,7 @@ def test_set_destination_all_ok(self):
self.assert_response(
response,
next_state="start",
- message={"message_type": "success", "message": "Batch Transfer complete"},
+ message={"message_type": "success", "body": "Batch Transfer complete"},
)
def test_set_destination_all_remaining_lines(self):
@@ -274,7 +274,7 @@ def test_set_destination_all_error_location_not_found(self):
},
message={
"message_type": "error",
- "message": "No location found for this barcode.",
+ "body": "No location found for this barcode.",
},
)
@@ -306,7 +306,7 @@ def test_set_destination_all_error_location_invalid(self):
"name": move_lines[0].location_dest_id.name,
},
},
- message={"message_type": "error", "message": "You cannot place it here"},
+ message={"message_type": "error", "body": "You cannot place it here"},
)
def test_set_destination_all_need_confirmation(self):
@@ -360,7 +360,7 @@ def test_set_destination_all_with_confirmation(self):
self.assert_response(
response,
next_state="start",
- message={"message_type": "success", "message": "Batch Transfer complete"},
+ message={"message_type": "success", "body": "Batch Transfer complete"},
)
@@ -471,7 +471,7 @@ def test_unload_scan_pack_wrong_barcode(self):
"name": self.move_lines[0].location_dest_id.name,
},
},
- message={"message_type": "error", "message": "Wrong bin"},
+ message={"message_type": "error", "body": "Wrong bin"},
)
@@ -679,7 +679,7 @@ def test_unload_scan_destination_last_line(self):
self.assert_response(
response,
next_state="start",
- message={"message": "Batch Transfer complete", "message_type": "success"},
+ message={"body": "Batch Transfer complete", "message_type": "success"},
)
def test_unload_scan_destination_error_location_not_found(self):
@@ -706,7 +706,7 @@ def test_unload_scan_destination_error_location_not_found(self):
},
message={
"message_type": "error",
- "message": "No location found for this barcode.",
+ "body": "No location found for this barcode.",
},
)
@@ -736,7 +736,7 @@ def test_unload_scan_destination_error_location_invalid(self):
"name": self.bin1_lines[0].location_dest_id.name,
},
},
- message={"message_type": "error", "message": "You cannot place it here"},
+ message={"message_type": "error", "body": "You cannot place it here"},
)
def test_unload_scan_destination_need_confirmation(self):
diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py
index c3af31cab5..81616439f4 100644
--- a/shopfloor/tests/test_scan_anything.py
+++ b/shopfloor/tests/test_scan_anything.py
@@ -20,9 +20,9 @@ def _test_response_ko(self, identifier, tried=None):
response = self.service.dispatch("scan", params=params)
message = response["message"]
self.assertEqual(message["message_type"], "error")
- self.assertIn("Record not found", message["message"])
+ self.assertIn("Record not found", message["body"])
for rec_type in tried:
- self.assertIn(rec_type, message["message"])
+ self.assertIn(rec_type, message["body"])
def test_scan_product(self):
record = self.product_b
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 6428638418..31d260afa5 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -76,10 +76,7 @@ def test_start(self):
self.assert_response(
response,
next_state="scan_location",
- message={
- "message_type": "info",
- "message": "Scan the destination location",
- },
+ message={"message_type": "info", "body": "Scan the destination location"},
data={
"id": self.ANY,
"location_src": {
@@ -111,7 +108,7 @@ def test_start_no_package_for_barcode(self):
next_state="start",
message={
"message_type": "error",
- "message": "The package NOTHING_SHOULD_EXIST_WITH: 👀 doesn't exist",
+ "body": "The package NOTHING_SHOULD_EXIST_WITH: 👀 doesn't exist",
},
)
@@ -137,7 +134,7 @@ def test_start_package_not_in_src_location(self):
next_state="start",
message={
"message_type": "error",
- "message": "You cannot work on a package (%s) outside of locations: %s"
+ "body": "You cannot work on a package (%s) outside of locations: %s"
% (
self.pack_a.name,
self.menu.picking_type_ids.default_location_src_id.name,
@@ -178,7 +175,7 @@ def test_start_move_in_different_picking_type(self):
next_state="start",
message={
"message_type": "error",
- "message": "An operation exists in Delivery Orders %s. You cannot"
+ "body": "An operation exists in Delivery Orders %s. You cannot"
" process it with this shopfloor scenario." % (picking.name,),
},
)
@@ -212,7 +209,7 @@ def test_start_move_already_exist(self):
next_state="confirm_start",
message={
"message_type": "warning",
- "message": "Operation's already running."
+ "body": "Operation's already running."
" Would you like to take it over?",
},
data={
@@ -278,7 +275,7 @@ def test_validate(self):
next_state="start",
message={
"message_type": "success",
- "message": "The pack has been moved, you can scan a new pack.",
+ "body": "The pack has been moved, you can scan a new pack.",
},
)
@@ -308,7 +305,7 @@ def test_validate_not_found(self):
next_state="start",
message={
"message_type": "error",
- "message": "This operation does not exist anymore.",
+ "body": "This operation does not exist anymore.",
},
)
@@ -340,7 +337,7 @@ def test_validate_location_not_found(self):
next_state="scan_location",
message={
"message_type": "error",
- "message": "No location found for this barcode.",
+ "body": "No location found for this barcode.",
},
data=self.ANY,
)
@@ -375,7 +372,7 @@ def test_validate_location_forbidden(self):
self.assert_response(
response,
next_state="scan_location",
- message={"message_type": "error", "message": "You cannot place it here"},
+ message={"message_type": "error", "body": "You cannot place it here"},
data=self.ANY,
)
@@ -469,7 +466,7 @@ def test_validate_location_with_confirm(self):
next_state="start",
message={
"message_type": "success",
- "message": "The pack has been moved, you can scan a new pack.",
+ "body": "The pack has been moved, you can scan a new pack.",
},
)
@@ -516,7 +513,7 @@ def test_cancel(self):
next_state="start",
message={
"message_type": "info",
- "message": "Canceled, you can scan a new pack.",
+ "body": "Canceled, you can scan a new pack.",
},
)
@@ -559,7 +556,7 @@ def test_cancel_already_canceled(self):
next_state="start",
message={
"message_type": "info",
- "message": "Canceled, you can scan a new pack.",
+ "body": "Canceled, you can scan a new pack.",
},
)
@@ -596,7 +593,7 @@ def test_cancel_already_done(self):
self.assert_response(
response,
next_state="start",
- message={"message_type": "info", "message": "Operation already processed."},
+ message={"message_type": "info", "body": "Operation already processed."},
)
def test_cancel_not_found(self):
@@ -612,6 +609,6 @@ def test_cancel_not_found(self):
next_state="start",
message={
"message_type": "error",
- "message": "This operation does not exist anymore.",
+ "body": "This operation does not exist anymore.",
},
)
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 348baf34ae..ec78610073 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -141,9 +141,7 @@ def test_start_no_operation(self):
next_state="start",
message={
"message_type": "error",
- "message": "No pending operation for package {}.".format(
- self.pack_a.name
- ),
+ "body": "No pending operation for package {}.".format(self.pack_a.name),
},
)
@@ -165,7 +163,7 @@ def test_start_barcode_not_known(self):
next_state="start",
message={
"message_type": "error",
- "message": "The package THIS_BARCODE_DOES_NOT_EXIST" " doesn't exist",
+ "body": "The package THIS_BARCODE_DOES_NOT_EXIST" " doesn't exist",
},
)
@@ -222,7 +220,7 @@ def test_start_pack_from_location_empty(self):
next_state="start",
message={
"message_type": "error",
- "message": "Location %s doesn't contain any package."
+ "body": "Location %s doesn't contain any package."
% (self.shelf2.name,),
},
)
@@ -260,7 +258,7 @@ def test_start_pack_from_location_several_packs(self):
next_state="start",
message={
"message_type": "warning",
- "message": "Several packages found in %s, please scan a package."
+ "body": "Several packages found in %s, please scan a package."
% (self.shelf1.name,),
},
)
@@ -286,7 +284,7 @@ def test_start_pack_outside_of_location(self):
next_state="start",
message={
"message_type": "error",
- "message": "You cannot work on a package (%s) outside of locations: %s"
+ "body": "You cannot work on a package (%s) outside of locations: %s"
% (self.pack_a.name, self.picking_type.default_location_src_id.name),
},
)
@@ -325,7 +323,7 @@ def test_start_already_started(self):
next_state="confirm_start",
message={
"message_type": "warning",
- "message": "Operation's already running."
+ "body": "Operation's already running."
" Would you like to take it over?",
},
data=self._response_package_level_data(package_level),
@@ -362,7 +360,7 @@ def test_validate(self):
next_state="start",
message={
"message_type": "success",
- "message": "The pack has been moved, you can scan a new pack.",
+ "body": "The pack has been moved, you can scan a new pack.",
},
)
@@ -434,7 +432,7 @@ def test_validate_completion_info(self):
},
message={
"message_type": "success",
- "message": "The pack has been moved, you can scan a new pack.",
+ "body": "The pack has been moved, you can scan a new pack.",
},
)
@@ -455,7 +453,7 @@ def test_validate_not_found(self):
next_state="start",
message={
"message_type": "error",
- "message": "This operation does not exist anymore.",
+ "body": "This operation does not exist anymore.",
},
)
@@ -488,7 +486,7 @@ def test_validate_location_not_found(self):
data=self.ANY,
message={
"message_type": "error",
- "message": "No location found for this barcode.",
+ "body": "No location found for this barcode.",
},
)
@@ -523,7 +521,7 @@ def test_validate_location_forbidden(self):
response,
next_state="scan_location",
data=self.ANY,
- message={"message_type": "error", "message": "You cannot place it here"},
+ message={"message_type": "error", "body": "You cannot place it here"},
)
def test_validate_location_to_confirm(self):
@@ -605,7 +603,7 @@ def test_validate_location_with_confirm(self):
next_state="start",
message={
"message_type": "success",
- "message": "The pack has been moved, you can scan a new pack.",
+ "body": "The pack has been moved, you can scan a new pack.",
},
)
@@ -651,7 +649,7 @@ def test_cancel(self):
next_state="start",
message={
"message_type": "info",
- "message": "Canceled, you can scan a new pack.",
+ "body": "Canceled, you can scan a new pack.",
},
)
@@ -693,7 +691,7 @@ def test_cancel_already_canceled(self):
next_state="start",
message={
"message_type": "info",
- "message": "Canceled, you can scan a new pack.",
+ "body": "Canceled, you can scan a new pack.",
},
)
@@ -730,7 +728,7 @@ def test_cancel_already_done(self):
self.assert_response(
response,
next_state="start",
- message={"message_type": "info", "message": "Operation already processed."},
+ message={"message_type": "info", "body": "Operation already processed."},
)
def test_cancel_not_found(self):
@@ -746,6 +744,6 @@ def test_cancel_not_found(self):
next_state="start",
message={
"message_type": "error",
- "message": "This operation does not exist anymore.",
+ "body": "This operation does not exist anymore.",
},
)
From 6c0d138a69534a03a3371947aef26e7d981563ba Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 18 May 2020 08:36:34 +0200
Subject: [PATCH 211/940] pack transfer: Fix test
I did not rebase before merging the PR :/
---
shopfloor/tests/test_single_pack_transfer.py | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index ec78610073..510eaced85 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -61,8 +61,16 @@ def _response_package_level_data(self, package_level):
return {
"id": package_level.id,
"name": package_level.package_id.name,
- "location_src": {"id": self.shelf1.id, "name": self.shelf1.name},
- "location_dest": {"id": self.shelf2.id, "name": self.shelf2.name},
+ "location_src": {
+ "id": self.shelf1.id,
+ "name": self.shelf1.name,
+ "barcode": self.shelf1.barcode,
+ },
+ "location_dest": {
+ "id": self.shelf2.id,
+ "name": self.shelf2.name,
+ "barcode": self.shelf2.barcode,
+ },
"picking": {
"id": self.picking.id,
"name": self.picking.name,
@@ -76,6 +84,7 @@ def _response_package_level_data(self, package_level):
"id": self.product_a.id,
"name": self.product_a.name,
"default_code": self.product_a.default_code,
+ "barcode": self.product_a.barcode,
"display_name": self.product_a.display_name,
},
}
From 9b1268b847bc948a6281455342bcb7ac383abf64 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 26 Feb 2020 11:56:31 +0100
Subject: [PATCH 212/940] cluster picking: implement /stock_issue
Co-authored-by: Simone Orsi
---
shopfloor/actions/inventory.py | 97 +++++-
shopfloor/services/cluster_picking.py | 67 +++-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_cluster_picking_base.py | 11 +-
.../tests/test_cluster_picking_stock_issue.py | 308 ++++++++++++++++++
5 files changed, 474 insertions(+), 10 deletions(-)
create mode 100644 shopfloor/tests/test_cluster_picking_stock_issue.py
diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py
index 7feeb36d44..a13eea5f0f 100644
--- a/shopfloor/actions/inventory.py
+++ b/shopfloor/actions/inventory.py
@@ -20,11 +20,104 @@ def create_draft_check_empty(self, location, product, ref=None):
name = _("Zero check issue on location {} ({})").format(location.name, ref)
else:
name = _("Zero check issue on location {}").format(location.name)
- inventory = self.env["stock.inventory"].create(
+ return self._create_draft_inventory(location, product, name)
+
+ def _inventory_exists(
+ self, location, product, package=None, lot=None, states=("draft", "confirm")
+ ):
+ """Return if an inventory for location and product exist"""
+ domain = [
+ ("location_ids", "=", location.id),
+ ("product_ids", "=", product.id),
+ ("state", "in", states),
+ ]
+ if package is not None:
+ domain.append(("package_id", "=", package.id))
+ if lot is not None:
+ domain.append(("lot_id", "=", lot.id))
+ return self.env["stock.inventory"].search_count(domain)
+
+ def _create_draft_inventory(self, location, product, name):
+ return self.env["stock.inventory"].create(
{
"name": name,
"location_ids": [(6, 0, location.ids)],
"product_ids": [(6, 0, product.ids)],
}
)
- return inventory
+
+ def create_control_stock(self, location, product, package, lot):
+ """Create a draft inventory so a user has to check a location
+
+ If a draft or in progress inventory already exists for the same
+ combination of product/package/lot, no inventory is created.
+ """
+ if not self._inventory_exists(location, product):
+ product_name = self._stock_issue_product_description(product, package, lot)
+
+ name = _("Control stock issue in location {} for {}").format(
+ location.name, product_name
+ )
+ self._create_draft_inventory(location, product, name)
+
+ def create_stock_issue(self, move, location, package, lot):
+ """Create an inventory for a stock issue
+
+ It reduces the quantity in a location in a way that:
+ * assigned move lines in other batch transfers stay assigned.
+ * assigned move lines in same batch but already picked stay assigned.
+ """
+ other_lines = self._stock_issue_get_related_move_lines(
+ move, location, package, lot
+ )
+ qty_to_keep = sum(other_lines.mapped("product_qty"))
+ values = self._stock_issue_inventory_values(
+ move, location, package, lot, qty_to_keep
+ )
+ inventory = self.env["stock.inventory"].create(values)
+ inventory.action_start()
+ inventory.action_validate()
+ move._action_assign()
+
+ def _stock_issue_get_related_move_lines(self, move, location, package, lot):
+ """Lookup for all the other moves lines that match given move line"""
+ domain = [
+ ("location_id", "=", location.id),
+ ("product_id", "=", move.product_id.id),
+ ("package_id", "=", package.id),
+ ("lot_id", "=", lot.id),
+ ("state", "=", "assigned"),
+ ]
+ return self.env["stock.move.line"].search(domain)
+
+ def _stock_issue_inventory_values(self, move, location, package, lot, line_qty):
+ name = _(
+ "{picking.name} stock correction in location {location.name} "
+ "for {product_desc}"
+ ).format(
+ picking=move.picking_id,
+ location=location,
+ product_desc=self._stock_issue_product_description(
+ move.product_id, package, lot
+ ),
+ )
+ line_values = {
+ "location_id": location.id,
+ "product_id": move.product_id.id,
+ "package_id": package.id,
+ "prod_lot_id": lot.id,
+ "product_qty": line_qty,
+ }
+ return {
+ "name": name,
+ "line_ids": [(0, 0, line_values)],
+ }
+
+ def _stock_issue_product_description(self, product, package, lot):
+ parts = []
+ if package:
+ parts.append(package.name)
+ parts.append(product.name)
+ if lot.name:
+ parts.append(_("Lot: ") + lot.name)
+ return " - ".join(parts)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index edc7f6844e..77ae1953e5 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -755,14 +755,19 @@ def stock_issue(self, move_line_id):
"""Declare a stock issue for a line
After errors in the stock, the user cannot take all the products
- because there is physically not enough goods. The move line is
- unassigned, and an inventory is created to reduce the quantity in the
+ because there is physically not enough goods. The move line is deleted
+ (unreserve), and an inventory is created to reduce the quantity in the
source location to prevent future errors until a correction. Beware:
the quantity already reserved by other lines should remain reserved so
the inventory's quantity must be set to the quantity of lines reserved
by other move lines (but not the current one).
- A second inventory is created in draft to have someone do an inventory.
+ The other lines not yet picked in the batch for the same product, lot,
+ package are unreserved as well (moves lines deleted, which unreserve
+ their quantity on the move).
+
+ A second inventory is created in draft to have someone do an inventory
+ check.
Transitions:
* start_line: when the batch still contains lines without destination
@@ -775,7 +780,61 @@ def stock_issue(self, move_line_id):
and the last line has a stock issue). In this case, this method *has*
to handle the closing of the batch to create backorders (_unload_end)
"""
- return self._response()
+ move_line = self.env["stock.move.line"].browse(move_line_id)
+ if not move_line.exists():
+ return self._response(
+ next_state="start", message=self.msg_store.unrecoverable_error()
+ )
+ batch = move_line.picking_id.batch_id
+
+ inventory = self.actions_for("inventory")
+ # create a draft inventory for a user to check
+ inventory.create_control_stock(
+ move_line.location_id,
+ move_line.product_id,
+ move_line.package_id,
+ move_line.lot_id,
+ )
+ move = move_line.move_id
+ lot = move_line.lot_id
+ package = move_line.package_id
+ location = move_line.location_id
+
+ # unreserve every lines for the same product/lot in the same batch and
+ # not done yet, so the same user doesn't have to declare 2 times the
+ # stock issue for the same thing!
+ domain = self._domain_stock_issue_unlink_lines(move_line)
+ unreserve_move_lines = move_line | self.env["stock.move.line"].search(domain)
+ unreserve_move_lines.unlink()
+
+ # Then, create an inventory with just enough qty so the other assigned
+ # move lines for the same product in other batches and the other move lines
+ # already picked stay assigned.
+ inventory.create_stock_issue(move, location, package, lot)
+ return self._pick_next_line(batch)
+
+ def _domain_stock_issue_unlink_lines(self, move_line):
+ # Since we have not enough stock, delete the move lines, which will
+ # in turn unreserve the moves. The moves lines we delete are those
+ # in the same batch (we don't want to interfere with other operators
+ # work, they'll have to declare a stock issue), and not yet started.
+ # The goal is to prevent the same operator to declare twice the same
+ # stock issue for the same product/lot/package.
+ batch = move_line.picking_id.batch_id
+ move = move_line.move_id
+ lot = move_line.lot_id
+ package = move_line.package_id
+ location = move_line.location_id
+ domain = [
+ ("location_id", "=", location.id),
+ ("product_id", "=", move.product_id.id),
+ ("package_id", "=", package.id),
+ ("lot_id", "=", lot.id),
+ ("state", "not in", ("cancel", "done")),
+ ("qty_done", "=", 0),
+ ("picking_id.batch_id", "=", batch.id),
+ ]
+ return domain
def change_pack_lot(self, move_line_id, barcode):
"""Change the expected pack or the lot for a line
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index f6c6713ffd..417f09e6ad 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -11,6 +11,7 @@
from . import test_cluster_picking_select
from . import test_cluster_picking_scan
from . import test_cluster_picking_skip
+from . import test_cluster_picking_stock_issue
from . import test_cluster_picking_unload
from . import test_checkout_base
from . import test_checkout_scan
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index 7d8333bbff..8ec69b8838 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -17,7 +17,9 @@ def setUp(self):
self.service = work.component(usage="cluster_picking")
@classmethod
- def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False):
+ def _simulate_batch_selected(
+ cls, batches, in_package=False, in_lot=False, fill_stock=True
+ ):
"""Create a state as if a batch was selected by the user
* The picking batch is in progress
@@ -28,9 +30,10 @@ def _simulate_batch_selected(cls, batches, in_package=False, in_lot=False):
all the products of the batch. It is enough for the current tests.
"""
pickings = batches.mapped("picking_ids")
- cls._fill_stock_for_moves(
- pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot
- )
+ if fill_stock:
+ cls._fill_stock_for_moves(
+ pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot
+ )
pickings.action_assign()
batches.write({"state": "in_progress", "user_id": cls.env.uid})
diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py
new file mode 100644
index 0000000000..e2094a52ba
--- /dev/null
+++ b/shopfloor/tests/test_cluster_picking_stock_issue.py
@@ -0,0 +1,308 @@
+from .test_cluster_picking_base import ClusterPickingCommonCase
+
+
+class ClusterPickingStockIssue(ClusterPickingCommonCase):
+ """Tests covering the /stock_issue endpoint
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ # quants already existing are from demo data
+ loc_ids = (cls.stock_location.id, cls.shelf1.id, cls.shelf2.id)
+ cls.env["stock.quant"].search([("location_id", "in", loc_ids)]).unlink()
+ cls.batch = cls._create_picking_batch(
+ [
+ [cls.BatchProduct(product=cls.product_a, quantity=10)],
+ [cls.BatchProduct(product=cls.product_a, quantity=5)],
+ [cls.BatchProduct(product=cls.product_a, quantity=20)],
+ [cls.BatchProduct(product=cls.product_a, quantity=10)],
+ [cls.BatchProduct(product=cls.product_a, quantity=7)],
+ ]
+ )
+
+ cls.moves = cls.batch.picking_ids.move_lines.sorted("id")
+ cls.move1, cls.move2, cls.move3, cls.move4, cls.move5 = cls.moves
+ cls.batch_other = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=30)]]
+ )
+ cls.dest_package = cls.env["stock.quant.package"].create({})
+
+ def _stock_issue(self, line, next_line=None):
+ response = self.service.dispatch(
+ "stock_issue", params={"move_line_id": line.id}
+ )
+ if next_line:
+ self.assert_response(
+ response, next_state="start_line", data=self._line_data(next_line)
+ )
+ else:
+ self.assert_response(
+ response,
+ next_state="unload_all",
+ data={
+ "id": self.batch.id,
+ "location_dest": {
+ "id": self.packing_location.id,
+ "name": self.packing_location.name,
+ },
+ "name": self.batch.name,
+ },
+ )
+
+ return response
+
+ def assert_location_qty_and_reserved(
+ self, location, expected_reserved_qty, lot=None
+ ):
+ quant_domain = [("location_id", "=", location.id)]
+ if lot:
+ quant_domain += [("lot_id", "=", lot.id)]
+ location_quants = self.env["stock.quant"].search(quant_domain)
+ self.assertEqual(sum(location_quants.mapped("quantity")), expected_reserved_qty)
+ self.assertEqual(
+ sum(location_quants.mapped("reserved_quantity")), expected_reserved_qty
+ )
+
+ def assert_stock_issue_inventories(
+ self, issue_picking, location, product, remaining_qty, lot=None
+ ):
+ inventories = self.env["stock.inventory"].search([], order="id desc", limit=2)
+ product_desc = product.name
+ if lot:
+ product_desc = "{} - Lot: {}".format(product_desc, lot.name)
+ self.assertRecordValues(
+ inventories,
+ [
+ {
+ # this one changed the quantity in the location to
+ # the quantity of the quant checked above
+ "state": "done",
+ "name": "{picking.name} stock correction in"
+ " location {location.name} for {product_desc}".format(
+ picking=issue_picking,
+ location=location,
+ product_desc=product_desc,
+ ),
+ },
+ {
+ # this one is draft and empty, has to be done by a user
+ "state": "draft",
+ "name": "Control stock issue in location {} for {}".format(
+ location.name, product_desc
+ ),
+ },
+ ],
+ )
+ self.assertRecordValues(
+ inventories[0].line_ids,
+ [
+ {
+ "product_id": product.id,
+ "location_id": location.id,
+ "product_qty": remaining_qty,
+ "package_id": False,
+ "prod_lot_id": lot.id if lot else False,
+ }
+ ],
+ )
+
+ def test_stock_issue_with_other_batch(self):
+ self._update_qty_in_location(self.shelf1, self.product_a, 25)
+ # The other batch will reserve 25 in shelf 1, now empty
+ self.batch_other.picking_ids.action_assign()
+
+ self._update_qty_in_location(self.shelf2, self.product_a, 100)
+ # and then, the other batch reserves 5 in shelf 2.
+ # We'll want to check that even if on our batch we have a stock issue,
+ # we never change anything in the batch of another operator.
+ self._simulate_batch_selected(self.batch_other, fill_stock=False)
+ self.assertEqual(
+ set(self.batch_other.picking_ids.mapped("state")), {"assigned"}
+ )
+
+ # At this point, we have a remaining quantity of 0 in shelf1
+ # and 95 in shelf2.
+
+ # all the moves of our batch should be reserved as we have enough
+ # stock
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+ self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"})
+
+ # the operator could pick the 2 first lines of the batch
+ self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package)
+ self._set_dest_package_and_done(self.move2.move_line_ids, self.dest_package)
+
+ # on the third move, the operator can't pick anymore in shelf1
+ # because there is nothing inside, they declare a stock issue
+ self._stock_issue(self.move3.move_line_ids)
+
+ self.assertRecordValues(
+ self.moves,
+ [
+ {"state": "assigned"},
+ {"state": "assigned"},
+ {"state": "confirmed"},
+ {"state": "confirmed"},
+ {"state": "confirmed"},
+ ],
+ )
+ expected_reserved_qty = (
+ self.move1.product_uom_qty
+ + self.move2.product_uom_qty
+ + sum(
+ self.batch_other.picking_ids.move_line_ids.filtered(
+ lambda l: l.location_id == self.shelf2
+ ).mapped("product_uom_qty")
+ )
+ )
+ # we should have a quant with 20 quantity and 20 reserved
+ # (5 for the other batch and 15 qty_done in this batch)
+ self.assert_location_qty_and_reserved(self.shelf2, expected_reserved_qty)
+ self.assert_stock_issue_inventories(
+ self.move3.picking_id,
+ self.shelf2,
+ self.move3.product_id,
+ expected_reserved_qty,
+ )
+
+ def test_stock_issue_several_move_lines(self):
+ self._update_qty_in_location(self.shelf1, self.product_a, 20)
+ # ensure these moves are reserved in shelf1
+ self.move1._action_assign()
+ self.move2._action_assign()
+
+ self._update_qty_in_location(self.shelf2, self.product_a, 100)
+ # reserve move3 first to ensure this one is reserved in both
+ # shelf1 and shelf2
+ self.move3._action_assign()
+
+ # all the remaining moves will be reserved in shelf2
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+ self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"})
+ # The moves of our batch are reserved as:
+ self.assertEqual(self.move1.move_line_ids.location_id, self.shelf1)
+ self.assertEqual(self.move2.move_line_ids.location_id, self.shelf1)
+ self.assertEqual(
+ self.move3.move_line_ids.mapped("location_id"), self.shelf1 | self.shelf2
+ )
+ self.assertEqual(self.move4.move_line_ids.location_id, self.shelf2)
+ self.assertEqual(self.move5.move_line_ids.location_id, self.shelf2)
+
+ line_shelf1 = self.move3.move_line_ids.filtered(
+ lambda l: l.location_id == self.shelf1
+ )
+ line_shelf2 = self.move3.move_line_ids.filtered(
+ lambda l: l.location_id == self.shelf2
+ )
+
+ # pick the first 2 moves
+ self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package)
+ self._set_dest_package_and_done(self.move2.move_line_ids, self.dest_package)
+ # the operator could pick the first part of move3 in shelf1
+ self._set_dest_package_and_done(line_shelf1, self.dest_package)
+
+ # on the third move, the operator can't pick anymore in shelf1
+ # because there is nothing inside, they declare a stock issue
+ self._stock_issue(line_shelf2)
+
+ self.assertRecordValues(
+ self.moves,
+ [
+ # move 1 and 2 aren't touched: they are in another location
+ {"state": "assigned"},
+ {"state": "assigned"},
+ {"state": "partially_available"},
+ {"state": "confirmed"},
+ {"state": "confirmed"},
+ ],
+ )
+ self.assertRecordValues(
+ # check that the other move line of the move was not altered
+ line_shelf1,
+ [
+ {
+ "location_id": self.shelf1.id,
+ "qty_done": 5.0,
+ "result_package_id": self.dest_package.id,
+ }
+ ],
+ )
+ self.assertFalse(line_shelf2.exists())
+ # the quantity in shelf1 should be the original one since we didn't have
+ # a stock issue here
+ self.assert_location_qty_and_reserved(self.shelf1, 20)
+ # since we declared the stock issue without picking anything, its
+ # quantity should be zero
+ self.assert_location_qty_and_reserved(self.shelf2, 0)
+ self.assert_stock_issue_inventories(
+ self.move3.picking_id, self.shelf2, self.move3.product_id, 0
+ )
+
+ def test_stock_issue_lot(self):
+ lot_a = self.env["stock.production.lot"].create(
+ {"product_id": self.product_a.id, "company_id": self.env.company.id}
+ )
+ lot_b = self.env["stock.production.lot"].create(
+ {"product_id": self.product_a.id, "company_id": self.env.company.id}
+ )
+ self._update_qty_in_location(
+ self.shelf2,
+ self.product_a,
+ self.move1.product_uom_qty + self.move5.product_uom_qty,
+ lot=lot_a,
+ )
+ # ensure that move 1 and 5 take lot_a (10 + 7 units), so all of them
+ self.move1._action_assign()
+ self.move5._action_assign()
+ # add stock for the rest of the moves
+ self._update_qty_in_location(self.shelf2, self.product_a, 100, lot=lot_b)
+ # reserve the remaining moves
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+ self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"})
+
+ # the operator could pick the 3 first lines of the batch
+ # move move1 with lot a
+ self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package)
+ # move move2 with lot b
+ self._set_dest_package_and_done(self.move2.move_line_ids, self.dest_package)
+
+ # on the third move, the operator can't pick anymore in the location,
+ # because there is nothing inside, they declare a stock issue
+ self._stock_issue(self.move3.move_line_ids, next_line=self.move5.move_line_ids)
+
+ self.assertRecordValues(
+ self.moves,
+ [
+ # still reserved because using lot a
+ {"state": "assigned"},
+ # still reserved because qty_done > 0
+ {"state": "assigned"},
+ # unreserved by the stock issue
+ {"state": "confirmed"},
+ # collaterally unreserved by the stock issue (same lot as the
+ # stock issue)
+ {"state": "confirmed"},
+ # still reserved because using lot a
+ {"state": "assigned"},
+ ],
+ )
+ # check the qty including lot a and lot b
+ total_reserved_qty = (
+ self.move1.product_uom_qty
+ + self.move2.product_uom_qty
+ + self.move5.product_uom_qty
+ )
+ self.assert_location_qty_and_reserved(self.shelf2, total_reserved_qty)
+ # this is the only product reserved for lot_b
+ expected_reserved_qty = self.move2.product_uom_qty
+ self.assert_location_qty_and_reserved(
+ self.shelf2, expected_reserved_qty, lot=lot_b
+ )
+ self.assert_stock_issue_inventories(
+ self.move3.picking_id,
+ self.shelf2,
+ self.move3.product_id,
+ expected_reserved_qty,
+ lot=lot_b,
+ )
From 0dcf3becaf06837aed8edede31c75ca93eb3d00c Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 11 May 2020 16:53:11 +0200
Subject: [PATCH 213/940] cluster picking: reassign moves after stock issue
In the process, I had to change the sorting of the lines to reflect
the sorting of the models, which broke the test checking that on
the split of a move line because we picked not enough qty, the next
line to pick should be the split line. It was working by chance,
so I added a parameter to force pick the line.
---
shopfloor/services/cluster_picking.py | 34 +++++++++++--
shopfloor/tests/test_cluster_picking_scan.py | 5 +-
.../tests/test_cluster_picking_stock_issue.py | 48 +++++++++++++++++--
3 files changed, 76 insertions(+), 11 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 77ae1953e5..561b002a2a 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -304,12 +304,25 @@ def confirm_start(self, picking_batch_id):
return self._response_batch_does_not_exist()
return self._pick_next_line(picking_batch)
- def _pick_next_line(self, batch, message=None):
- next_line = self._next_line_for_pick(batch)
+ def _pick_next_line(self, batch, message=None, force_line=None):
+ if force_line:
+ next_line = force_line
+ else:
+ next_line = self._next_line_for_pick(batch)
if not next_line:
return self.prepare_unload(batch.id)
return self._response_for_start_line(next_line, message=message)
+ @staticmethod
+ def _sort_key_lines(line):
+ return (
+ line.location_id,
+ line.shopfloor_postponed,
+ line.move_id.sequence,
+ line.move_id.id,
+ line.id,
+ )
+
def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func)
# TODO test line sorting and all these methods to retrieve lines
@@ -318,7 +331,7 @@ def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
# so that the picker start w/ products in the same location.
# Postponed lines must come always
# after ALL the other lines in the batch are processed.
- return lines.sorted(key=lambda x: (x.location_id, x.shopfloor_postponed))
+ return lines.sorted(key=self._sort_key_lines)
def _lines_to_pick(self, picking_batch):
return self._lines_for_picking_batch(
@@ -538,6 +551,10 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
return self._response_for_start(
message=self.msg_store.unrecoverable_error()
)
+
+ # store a new line if we have split our line (not enough qty)
+ new_line = self.env["stock.move.line"]
+
rounding = move_line.product_uom_id.rounding
compare = float_compare(
quantity, move_line.product_uom_qty, precision_rounding=rounding
@@ -559,8 +576,7 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
# has to pick some goods from another place because the location
# contained less items than expected)
remaining = move_line.product_uom_qty - quantity
- # TODO it must be the next line to process
- move_line.copy({"product_uom_qty": remaining, "qty_done": 0})
+ new_line = move_line.copy({"product_uom_qty": remaining, "qty_done": 0})
move_line.product_uom_qty = quantity
search = self.actions_for("search")
@@ -598,6 +614,9 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
message=self.msg_store.x_units_put_in_package(
move_line.qty_done, move_line.product_id, move_line.result_package_id
),
+ # if we split the move line, we want to process the one generated by the
+ # split right now
+ force_line=new_line,
)
def _planned_qty_in_location_is_empty(self, product, location):
@@ -805,12 +824,17 @@ def stock_issue(self, move_line_id):
# stock issue for the same thing!
domain = self._domain_stock_issue_unlink_lines(move_line)
unreserve_move_lines = move_line | self.env["stock.move.line"].search(domain)
+ unreserve_moves = unreserve_move_lines.mapped("move_id").sorted()
unreserve_move_lines.unlink()
# Then, create an inventory with just enough qty so the other assigned
# move lines for the same product in other batches and the other move lines
# already picked stay assigned.
inventory.create_stock_issue(move, location, package, lot)
+
+ # try to reassign the moves in case we have stock in another location
+ unreserve_moves._action_assign()
+
return self._pick_next_line(batch)
def _domain_stock_issue_unlink_lines(self, move_line):
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index bf37e8767c..9cdd4a2ce6 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -455,6 +455,9 @@ def test_scan_destination_pack_quantity_more(self):
def test_scan_destination_pack_quantity_less(self):
"""Pick less units than expected"""
line = self.one_line_picking.move_line_ids
+ # when we pick less quantity than expected, the line is split
+ # and the user is proposed to pick the next line for the remaining
+ # quantity
response = self.service.dispatch(
"scan_destination_pack",
params={
@@ -477,8 +480,6 @@ def test_scan_destination_pack_quantity_less(self):
self.assert_response(
response,
next_state="start_line",
- # TODO ensure the duplicated line is the next line, it works now but
- # maybe only by chance
data=self._line_data(new_line),
message={
"message_type": "success",
diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py
index e2094a52ba..deaa8204d9 100644
--- a/shopfloor/tests/test_cluster_picking_stock_issue.py
+++ b/shopfloor/tests/test_cluster_picking_stock_issue.py
@@ -28,13 +28,18 @@ def setUpClass(cls, *args, **kwargs):
)
cls.dest_package = cls.env["stock.quant.package"].create({})
- def _stock_issue(self, line, next_line=None):
+ def _stock_issue(self, line, next_line_func=None):
response = self.service.dispatch(
"stock_issue", params={"move_line_id": line.id}
)
- if next_line:
+ # use a function/lambda to delay the read of the next line,
+ # when calling _stock_issue(), the move_line may not exist and
+ # be created during the call to the stock_issue service
+ if next_line_func:
self.assert_response(
- response, next_state="start_line", data=self._line_data(next_line)
+ response,
+ next_state="start_line",
+ data=self._line_data(next_line_func()),
)
else:
self.assert_response(
@@ -269,7 +274,9 @@ def test_stock_issue_lot(self):
# on the third move, the operator can't pick anymore in the location,
# because there is nothing inside, they declare a stock issue
- self._stock_issue(self.move3.move_line_ids, next_line=self.move5.move_line_ids)
+ self._stock_issue(
+ self.move3.move_line_ids, next_line_func=lambda: self.move5.move_line_ids
+ )
self.assertRecordValues(
self.moves,
@@ -306,3 +313,36 @@ def test_stock_issue_lot(self):
expected_reserved_qty,
lot=lot_b,
)
+
+ def test_stock_issue_reserve_elsewhere(self):
+ self._update_qty_in_location(self.shelf1, self.product_a, 100)
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+ # now, everything is reserved in shelf1 as we had enough stock
+ self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"})
+
+ # put stock in shelf2, so we can test the outcome: goods should be
+ # reserved in shelf2 after a stock issue
+ self._update_qty_in_location(self.shelf2, self.product_a, 100)
+
+ # the operator picks the first line
+ self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package)
+
+ # and has a stock issue on the second line
+ # because there is nothing inside, they declare a stock issue
+ self._stock_issue(
+ self.move2.move_line_ids, next_line_func=lambda: self.move2.move_line_ids
+ )
+
+ # the inventory should have been done for shelf1, and all the remaining
+ # moves after move1 (already picked) should have been reserved in
+ # shelf2
+ self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"})
+ self.assertEqual(self.move1.move_line_ids.location_id, self.shelf1)
+ # all the following moves have been reserved in shelf2 as we still have
+ # stock there
+ self.assertEqual(
+ (self.move2 | self.move3 | self.move4 | self.move5).mapped(
+ "move_line_ids.location_id"
+ ),
+ self.shelf2,
+ )
From c5bb1d7906014cd9b098033d556b3a932fb365ed Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 6 Mar 2020 12:44:18 +0100
Subject: [PATCH 214/940] cluster picking: implement /change_pack_lot
When an operator picks a move line, they can use a button to replace the
package or the lot.
Replacing a lot
---------------
The lot can be replaced when:
* the move was not moving a package
* or the move was moving a package, but the lot is in a single
package in the current move line's location
Replacing the lot may lead negative quants if the available quantity
was insuficient, but physically, the quantity was there, so the
situation should balance itself eventually, and when it happens, a draft
inventory is created in the location for a user to check.
Replacing a package
-------------------
The package can be replaced when:
* the package content is the same, same products and quantities, but the
lots can be different
* the package is in another package level with the "is_done" flag, in
this situation, the package is really not supposed to be here
Some tricky situations may happen:
* Physically, the replacement package is in the move line's location,
but in the quants, it's elsewhere. It means it's an error in the data,
and we should not prevent someone to pick it. The quant is updated
with inventory moves to move the package in the correct location
before doing the replacement.
* The package is already reserved for another package level (not
"is_done" yet). The operator may be allowed to choose another identical
package, in this case, it switches the packages between the 2
transfers (we know their content is the same, except the lots).
---
shopfloor/actions/inventory.py | 40 +-
shopfloor/actions/message.py | 18 +
shopfloor/models/__init__.py | 1 +
shopfloor/models/stock_package_level.py | 28 +
shopfloor/services/cluster_picking.py | 202 ++++++-
shopfloor/tests/__init__.py | 1 +
.../test_cluster_picking_change_pack_lot.py | 535 ++++++++++++++++++
7 files changed, 818 insertions(+), 7 deletions(-)
create mode 100644 shopfloor/models/stock_package_level.py
create mode 100644 shopfloor/tests/test_cluster_picking_change_pack_lot.py
diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py
index a13eea5f0f..9f71fd2243 100644
--- a/shopfloor/actions/inventory.py
+++ b/shopfloor/actions/inventory.py
@@ -46,7 +46,38 @@ def _create_draft_inventory(self, location, product, name):
}
)
- def create_control_stock(self, location, product, package, lot):
+ def move_package_quants_to_location(self, package, dest_location):
+ """Create inventories to move a package to a different location
+
+ It should be called when the package is - in real life - already in
+ the destination. It creates an inventory to remove the package from
+ the source location and a second inventory to place the package
+ in the destination (to reflect the reality).
+
+ The source location is the current location of the package.
+ """
+ quant_values = []
+ # sudo and the key in context activate is_inventory_mode on quants
+ quants = package.quant_ids.sudo().with_context(inventory_mode=True)
+ for quant in quants:
+ quantity = quant.quantity
+ quant.inventory_quantity = 0
+ quant_values.append(self._quant_move_values(quant, dest_location, quantity))
+
+ quant_model = self.env["stock.quant"].sudo().with_context(inventory_mode=True)
+ quant_model.create(quant_values)
+
+ def _quant_move_values(self, quant, location, quantity):
+ return {
+ "product_id": quant.product_id.id,
+ "inventory_quantity": quantity,
+ "location_id": location.id,
+ "lot_id": quant.lot_id.id,
+ "package_id": quant.package_id.id,
+ "owner_id": quant.owner_id.id,
+ }
+
+ def create_control_stock(self, location, product, package, lot, name=None):
"""Create a draft inventory so a user has to check a location
If a draft or in progress inventory already exists for the same
@@ -55,9 +86,10 @@ def create_control_stock(self, location, product, package, lot):
if not self._inventory_exists(location, product):
product_name = self._stock_issue_product_description(product, package, lot)
- name = _("Control stock issue in location {} for {}").format(
- location.name, product_name
- )
+ if not name:
+ name = _("Control stock issue in location {} for {}").format(
+ location.name, product_name
+ )
self._create_draft_inventory(location, product, name)
def create_stock_issue(self, move, location, package, lot):
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 7a6f099c84..f565b5e823 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -141,6 +141,18 @@ def several_packs_in_location(self, location):
),
}
+ def no_lot_for_barcode(self, barcode):
+ return {
+ "message_type": "error",
+ "body": _("No lot found for {}".format(barcode)),
+ }
+
+ def lot_on_wrong_product(self, barcode):
+ return {
+ "message_type": "error",
+ "body": _("Lot {} is for another product.").format(barcode),
+ }
+
def several_lots_in_location(self, location):
return {
"message_type": "warning",
@@ -167,6 +179,12 @@ def unrecoverable_error(self):
"body": _("Unrecoverable error, please restart."),
}
+ def package_different_content(self, package):
+ return {
+ "message_type": "error",
+ "body": _("Package {} has a different content.").format(package.name),
+ }
+
def x_units_put_in_package(self, qty, product, package):
return {
"message_type": "success",
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index 46d5a1ccf6..b44bcc12eb 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -3,6 +3,7 @@
from . import shopfloor_profile
from . import stock_location
from . import stock_move_line
+from . import stock_package_level
from . import stock_picking
from . import stock_picking_batch
from . import stock_quant_package
diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py
new file mode 100644
index 0000000000..1db120cc2f
--- /dev/null
+++ b/shopfloor/models/stock_package_level.py
@@ -0,0 +1,28 @@
+from odoo import models
+
+
+class StockPackageLevel(models.Model):
+ _inherit = "stock.package_level"
+
+ def replace_package(self, new_package):
+ """Replace a package on an assigned package level and related records
+
+ The replacement package must have the same properties (same products
+ and quantities).
+ """
+ if self.state not in ("new", "assigned"):
+ return
+
+ move_lines = self.move_line_ids
+ # the write method on stock.move.line updates the reservation on quants
+ move_lines.package_id = new_package
+ # when a package is set on a line, the destination package is the same
+ # by default
+ move_lines.result_package_id = new_package
+ for quant in new_package.quant_ids:
+ for line in move_lines:
+ if line.product_id == quant.product_id:
+ line.lot_id = quant.lot_id
+ line.owner_id = quant.owner_id
+
+ self.package_id = new_package
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 561b002a2a..6c7d980bd1 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -113,6 +113,13 @@ def _response_for_scan_destination(self, move_line, message=None):
)
return self._response(next_state="scan_destination", data=data, message=message)
+ def _response_for_change_pack_lot(self, move_line, message=None):
+ return self._response(
+ next_state="change_pack_lot",
+ data=self._data_move_line(move_line),
+ message=message,
+ )
+
def _response_for_zero_check(self, move_line):
data = {
"id": move_line.id,
@@ -876,9 +883,195 @@ def change_pack_lot(self, move_line_id, barcode):
Transitions:
* scan_destination: the pack or the lot could be changed
- * start_line: any error occurred during the change
+ * change_pack_lot: any error occurred during the change
"""
- return self._response()
+ move_line = self.env["stock.move.line"].browse(move_line_id)
+ if not move_line.exists():
+ return self._response(
+ next_state="start", message=self.msg_store.unrecoverable_error()
+ )
+ search = self.actions_for("search")
+ lot = search.lot_from_scan(barcode)
+ if lot:
+ # If the lot is part of a package, what we really want
+ # is not to change the lot, but change the package (which will
+ # in turn change the lot altogether), but we have to pay attention
+ # to some things:
+ # * cannot replace a package by a lot without package (qty may be
+ # different, ...)
+ # * if we have several packages for the same lot, we can't know which
+ # one the operator is moving, ask to scan a package
+ lot_package_quants = self.env["stock.quant"].search(
+ [
+ ("lot_id", "=", lot.id),
+ ("location_id", "=", move_line.location_id.id),
+ ("package_id", "!=", False),
+ ("quantity", ">", 0),
+ ]
+ )
+ if move_line.package_id and not lot_package_quants:
+ return self._response_for_change_pack_lot(
+ move_line,
+ message={
+ "message_type": "error",
+ "body": _("Lot {} is not a package.").format(lot.name),
+ },
+ )
+
+ if len(lot_package_quants) == 1:
+ package = lot_package_quants.package_id
+ return self._change_pack_lot_change_package(move_line, package)
+ elif len(lot_package_quants) > 1:
+ return self._response_for_change_pack_lot(
+ move_line,
+ message=self.msg_store.several_packs_in_location(
+ move_line.location_id
+ ),
+ )
+
+ return self._change_pack_lot_change_lot(move_line, lot)
+
+ package = search.package_from_scan(barcode)
+ if package:
+ return self._change_pack_lot_change_package(move_line, package)
+
+ return self._response_for_change_pack_lot(
+ move_line,
+ message={
+ "message_type": "warning",
+ "body": _("No package or lot found for barcode {}").format(barcode),
+ },
+ )
+
+ def _change_pack_lot_change_lot(self, move_line, lot):
+ inventory = self.actions_for("inventory")
+ product = move_line.product_id
+ if lot.product_id != product:
+ return self._response_for_change_pack_lot(
+ move_line, message=self.msg_store.lot_on_wrong_product(lot.name)
+ )
+ previous_lot = move_line.lot_id
+ # Changing the lot on the move line updates the reservation
+ # on the quants
+ move_line.lot_id = lot
+
+ success_body = _("Lot {} replaced by lot {}.").format(
+ previous_lot.name, lot.name
+ )
+ # check that we are supposed to have enough of this lot in the
+ # source location
+ quant = lot.quant_ids.filtered(lambda q: q.location_id == move_line.location_id)
+ if not quant:
+ # not supposed to have this lot here... (if there is a quant
+ # but not enough quantity we don't care here: user will report
+ # a stock issue)
+ inventory.create_control_stock(
+ move_line.location_id,
+ move_line.product_id,
+ move_line.package_id,
+ move_line.lot_id,
+ _("Pick: stock issue on lot: {} found in {}").format(
+ lot.name, move_line.location_id.name
+ ),
+ )
+ success_body += _(" A draft inventory has been created for control.")
+
+ return self._response_for_scan_destination(
+ move_line, message={"message_type": "info", "body": success_body}
+ )
+
+ def _package_identical_move_lines_qty(self, package, move_lines):
+ grouped_quants = {}
+ for quant in package.quant_ids:
+ grouped_quants.setdefault(quant.product_id, 0)
+ grouped_quants[quant.product_id] += quant.quantity
+
+ grouped_lines = {}
+ for move_line in move_lines:
+ grouped_lines.setdefault(move_line.product_id, 0)
+ grouped_lines[move_line.product_id] += move_line.product_uom_qty
+
+ return grouped_quants == grouped_lines
+
+ def _change_pack_lot_change_package(self, move_line, package):
+ inventory = self.actions_for("inventory")
+
+ package_level = move_line.package_level_id
+ # several move lines can be moved by the package level, we'll have
+ # to update all of them
+ move_lines = package_level.move_line_ids
+
+ # prevent to replace a package by a package with a different content
+ identical_content = self._package_identical_move_lines_qty(package, move_lines)
+ if not identical_content:
+ return self._response_for_change_pack_lot(
+ move_line, message=self.msg_store.package_different_content(package)
+ )
+
+ previous_package = move_line.package_id
+
+ if package.location_id != move_line.location_id:
+ # the package has been scanned in the current location so we know its
+ # a mistake in the data... fix the quant to move the package here
+ inventory.move_package_quants_to_location(package, move_line.location_id)
+
+ # search a package level which would already move the scanned package
+ reserved_level = (
+ self.env["stock.package_level"].search([("package_id", "=", package.id)])
+ # not possible to search on state
+ .filtered(lambda level: level.state in ("new", "assigned"))
+ )
+ if reserved_level:
+ reserved_level.ensure_one()
+ if reserved_level.is_done:
+ # Not really supposed to happen: if someone sets is_done, the package
+ # should no longer be here! But we have to check this and inform the
+ # user in any case.
+ return self._response_for_change_pack_lot(
+ move_line,
+ message={
+ "message_type": "error",
+ "body": _(
+ "Package {} cannot be picked, already moved by transfer {}"
+ ).format(package.name, reserved_level.picking_id.name),
+ },
+ )
+
+ # Switch the package with the level which was moving it, as we know
+ # that:
+ # * only one package level at a time is supposed to move a package
+ # * the content of the other package is the same (as we checked the
+ # content is the same as the current move lines)
+ # * if we left the reserved level with the scanned package, we would
+ # have 2 levels for the same package and odoo would unreserve the
+ # move lines as soon as we confirm the current moves
+ # Considering this, we should be safe to interchange the packages
+ if reserved_level:
+ # Ignore updates on quant reservation, which would prevent to switch
+ # 2 packages between 2 assigned package levels: when writing the
+ # package of the second level to the first level, it would unreserve
+ # it because the second level is still using the package.
+ # But here, we know they both available before and must be available after!
+ reserved_level.with_context(bypass_reservation_update=True).replace_package(
+ previous_package
+ )
+ package_level.with_context(bypass_reservation_update=True).replace_package(
+ package
+ )
+ else:
+ # when we are not switching packages, we expect the quant
+ # reservations to be aligned
+ package_level.replace_package(package)
+
+ return self._response_for_scan_destination(
+ move_line,
+ message={
+ "message_type": "info",
+ "body": _("Package {} replaced by package {}").format(
+ previous_package.name, package.name
+ ),
+ },
+ )
def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
"""Set the destination for all the lines of the batch with a dest. package
@@ -1202,6 +1395,7 @@ def _states(self):
"unload_single": self._schema_for_unload_single,
"unload_set_destination": self._schema_for_unload_single,
"confirm_unload_set_destination": self._schema_for_unload_single,
+ "change_pack_lot": self._schema_for_single_line_details,
}
def find_batch(self):
@@ -1296,7 +1490,9 @@ def stock_issue(self):
)
def change_pack_lot(self):
- return self._response_schema(next_states={"scan_destination", "start_line"})
+ return self._response_schema(
+ next_states={"change_pack_lot", "scan_destination"}
+ )
def set_destination_all(self):
return self._response_schema(
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 417f09e6ad..640b06ae81 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -12,6 +12,7 @@
from . import test_cluster_picking_scan
from . import test_cluster_picking_skip
from . import test_cluster_picking_stock_issue
+from . import test_cluster_picking_change_pack_lot
from . import test_cluster_picking_unload
from . import test_checkout_base
from . import test_checkout_scan
diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
new file mode 100644
index 0000000000..5e5c77a561
--- /dev/null
+++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
@@ -0,0 +1,535 @@
+from collections import namedtuple
+
+from odoo.tests.common import Form
+
+from .test_cluster_picking_base import ClusterPickingCommonCase
+
+
+class ClusterPickingChangePackLotCommon(ClusterPickingCommonCase):
+
+ # used by _create_package_in_location
+ PackageContent = namedtuple(
+ "PackageContent",
+ # recordset of the product,
+ # quantity in float
+ # recordset of the lot (optional)
+ "product quantity lot",
+ )
+
+ def _create_package_in_location(self, location, content):
+ """Create a package and quants in a location
+
+ content is a list of PackageContent
+ """
+ package = self.env["stock.quant.package"].create({})
+ for product, quantity, lot in content:
+ self._update_qty_in_location(
+ location, product, quantity, package=package, lot=lot
+ )
+ return package
+
+ def _create_lot(self, product):
+ return self.env["stock.production.lot"].create(
+ {"product_id": product.id, "company_id": self.env.company.id}
+ )
+
+ def _test_change_pack_lot(self, line, barcode, success=True, message=None):
+ response = self.service.dispatch(
+ "change_pack_lot", params={"move_line_id": line.id, "barcode": barcode},
+ )
+ if success:
+ self.assert_response(
+ response,
+ message=message,
+ next_state="scan_destination",
+ data=self._line_data(line),
+ )
+ else:
+ self.assert_response(
+ response,
+ message=message,
+ next_state="change_pack_lot",
+ data=self._line_data(line),
+ )
+
+ def _skip_line(self, line, next_line=None):
+ response = self.service.dispatch("skip_line", params={"move_line_id": line.id})
+ if next_line:
+ self.assert_response(
+ response, next_state="start_line", data=self._line_data(next_line)
+ )
+ return response
+
+ def assert_quant_reserved_qty(self, move_line, qty_func, package=None, lot=None):
+ domain = [
+ ("location_id", "=", move_line.location_id.id),
+ ("product_id", "=", move_line.product_id.id),
+ ]
+ if package:
+ domain.append(("package_id", "=", package.id))
+ if lot:
+ domain.append(("lot_id", "=", lot.id))
+ quant = self.env["stock.quant"].search(domain)
+ self.assertEqual(quant.reserved_quantity, qty_func())
+
+ def assert_quant_package_qty(self, location, package, qty_func):
+ quant = self.env["stock.quant"].search(
+ [("location_id", "=", location.id), ("package_id", "=", package.id)]
+ )
+ self.assertEqual(quant.quantity, qty_func())
+
+ def assert_control_stock_inventory(self, location, product, lot):
+ inventory = self.env["stock.inventory"].search([], order="id desc", limit=1)
+ self.assertRecordValues(
+ inventory,
+ [
+ {
+ "state": "draft",
+ "product_ids": product.ids,
+ "name": "Pick: stock issue on lot: {} found in {}".format(
+ lot.name, location.name
+ ),
+ },
+ ],
+ )
+
+
+class ClusterPickingChangePackLotCase(ClusterPickingChangePackLotCommon):
+ """Tests covering the /change_pack_lot endpoint"""
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+ cls.batch = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=10)]]
+ )
+
+ def test_change_pack_lot_change_pack_ok(self):
+ initial_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+
+ # ensure we have our new package in the same location
+ new_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+
+ line = self.batch.picking_ids.move_line_ids
+ self._test_change_pack_lot(
+ line,
+ new_package.name,
+ success=True,
+ message={
+ "message_type": "info",
+ "body": "Package {} replaced by package {}".format(
+ initial_package.name, new_package.name
+ ),
+ },
+ )
+
+ self.assertRecordValues(
+ line, [{"package_id": new_package.id, "result_package_id": new_package.id}]
+ )
+
+ self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
+ self.assert_quant_reserved_qty(
+ line, lambda: line.product_qty, package=new_package
+ )
+
+ def test_change_pack_lot_change_pack_different_location(self):
+ initial_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+
+ # initial_package from shelf1 will be selected in our move line
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+
+ # put a package in shelf2 in the system, but we assume that in real,
+ # the operator put it in shelf1
+ new_package = self._create_package_in_location(
+ self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+
+ line = self.batch.picking_ids.move_line_ids
+ # when the operator wants to pick the initial package, in shelf1, the new
+ # package is in front of the other so they want to change the package
+ self._test_change_pack_lot(
+ line,
+ new_package.name,
+ success=True,
+ message={
+ "message_type": "info",
+ "body": "Package {} replaced by package {}".format(
+ initial_package.name, new_package.name
+ ),
+ },
+ )
+
+ self.assertRecordValues(
+ line, [{"package_id": new_package.id, "result_package_id": new_package.id}]
+ )
+ self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
+ # check that reservations have been updated
+ self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0)
+ self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
+ self.assert_quant_reserved_qty(
+ line, lambda: line.product_qty, package=new_package
+ )
+
+ def test_change_pack_lot_change_lot_in_package_ok(self):
+ self.product_a.tracking = "lot"
+ initial_lot = self._create_lot(self.product_a)
+ initial_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)]
+ )
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+ # ensure we have our new package in the same location
+ new_lot = self._create_lot(self.product_a)
+ new_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=new_lot)]
+ )
+ line = self.batch.picking_ids.move_line_ids
+ self._test_change_pack_lot(
+ line,
+ new_lot.name,
+ success=True,
+ message={
+ "message_type": "info",
+ "body": "Package {} replaced by package {}".format(
+ initial_package.name, new_package.name
+ ),
+ },
+ )
+
+ self.assertRecordValues(
+ line,
+ [
+ {
+ "package_id": new_package.id,
+ "result_package_id": new_package.id,
+ "lot_id": new_lot.id,
+ }
+ ],
+ )
+ self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
+ self.assert_quant_reserved_qty(
+ line, lambda: line.product_qty, package=new_package
+ )
+
+ def test_change_pack_lot_change_lot_in_several_packages_error(self):
+ self.product_a.tracking = "lot"
+ initial_lot = self._create_lot(self.product_a)
+ self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)]
+ )
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+ line = self.batch.picking_ids.move_line_ids
+ # create 2 packages for the same new lot in the same location
+ new_lot = self._create_lot(self.product_a)
+ self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)]
+ )
+ self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)]
+ )
+ self._test_change_pack_lot(
+ line,
+ new_lot.name,
+ success=False,
+ message={
+ "message_type": "warning",
+ "body": "Several packages found in {},"
+ " please scan a package.".format(self.shelf1.name),
+ },
+ )
+
+ def test_change_pack_lot_change_lot_from_package_error(self):
+ # we shouldn't be allowed to replace a package by a lot
+ # if the lot is not a package in the quants (because we
+ # could then replace a package by a single unit)
+ self.product_a.tracking = "lot"
+ initial_lot = self._create_lot(self.product_a)
+ self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)]
+ )
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+ line = self.batch.picking_ids.move_line_ids
+ # create a lot and put a unit in the location without package
+ new_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, line.product_id, 1, lot=new_lot)
+ self._test_change_pack_lot(
+ line,
+ new_lot.name,
+ success=False,
+ message={
+ "message_type": "error",
+ "body": "Lot {} is not a package.".format(new_lot.name),
+ },
+ )
+
+ def test_change_pack_lot_change_lot_ok(self):
+ initial_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot)
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+ line = self.batch.picking_ids.move_line_ids
+ source_location = line.location_id
+ new_lot = self._create_lot(self.product_a)
+ # ensure we have our new package in the same location
+ self._update_qty_in_location(source_location, line.product_id, 10, lot=new_lot)
+ self._test_change_pack_lot(
+ line,
+ new_lot.name,
+ success=True,
+ message={
+ "message_type": "info",
+ "body": "Lot {} replaced by lot {}.".format(
+ initial_lot.name, new_lot.name
+ ),
+ },
+ )
+
+ self.assertRecordValues(line, [{"lot_id": new_lot.id}])
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot)
+ self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot)
+
+ def test_change_pack_lot_change_lot_different_location_ok(self):
+ self.product_a.tracking = "lot"
+ initial_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot)
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+ line = self.batch.picking_ids.move_line_ids
+ new_lot = self._create_lot(self.product_a)
+ # ensure we have our new package in a different location
+ self._update_qty_in_location(self.shelf2, line.product_id, 10, lot=new_lot)
+ self._test_change_pack_lot(
+ line,
+ new_lot.name,
+ success=True,
+ message={
+ "message_type": "info",
+ "body": "Lot {} replaced by lot {}. A draft inventory has"
+ " been created for control.".format(initial_lot.name, new_lot.name),
+ },
+ )
+
+ self.assertRecordValues(line, [{"lot_id": new_lot.id}])
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot)
+ self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot)
+ self.assert_control_stock_inventory(self.shelf1, line.product_id, new_lot)
+
+
+class ClusterPickingChangePackLotCaseSpecial(ClusterPickingChangePackLotCommon):
+ """Tests covering the /change_pack_lot endpoint
+
+ Special cases where we use a custom batch transfer
+ """
+
+ @classmethod
+ def setUpClass(cls, *args, **kwargs):
+ super().setUpClass(*args, **kwargs)
+
+ def _create_picking_with_package_level(self, packages):
+ picking_form = Form(self.env["stock.picking"])
+ picking_form.partner_id = self.customer
+ picking_form.origin = "test"
+ picking_form.picking_type_id = self.picking_type
+ picking_form.location_id = self.stock_location
+ picking_form.location_dest_id = self.packing_location
+ for package in packages:
+ with picking_form.package_level_ids_details.new() as move:
+ move.package_id = package
+ picking = picking_form.save()
+ picking.action_confirm()
+ picking.action_assign()
+ return picking
+
+ def _create_batch_with_pickings(self, pickings):
+ batch_form = Form(self.env["stock.picking.batch"])
+ for picking in pickings:
+ batch_form.picking_ids.add(picking)
+ batch = batch_form.save()
+ return batch
+
+ def test_change_pack_lot_change_pack_different_content_error(self):
+ # create the initial package, that will be reserved first
+ initial_package = self._create_package_in_location(
+ self.shelf1,
+ [
+ self.PackageContent(self.product_a, 10, lot=None),
+ self.PackageContent(self.product_b, 10, lot=None),
+ ],
+ )
+ picking = self._create_picking_with_package_level(initial_package)
+ batch = self._create_batch_with_pickings(picking)
+ self._simulate_batch_selected(batch, fill_stock=False)
+
+ # create a new package in the same location
+ # with a different content
+ new_package = self._create_package_in_location(
+ self.shelf1,
+ [
+ self.PackageContent(self.product_a, 10, lot=None),
+ self.PackageContent(self.product_b, 8, lot=None),
+ ],
+ )
+
+ lines = batch.picking_ids.move_line_ids
+ # try to use the new package, which has a different content,
+ # not accepted
+ self._test_change_pack_lot(
+ lines[0],
+ new_package.name,
+ success=False,
+ message={
+ "message_type": "error",
+ "body": "Package {} has a different content.".format(new_package.name),
+ },
+ )
+
+ def test_change_pack_lot_change_pack_multi_content_with_lot(self):
+ (self.product_a + self.product_b).tracking = "lot"
+ # create a package with 2 products tracked by lot, stored in shelf1
+ # this package is reserved first on the move line
+ initial_lot_a = self._create_lot(self.product_a)
+ initial_lot_b = self._create_lot(self.product_b)
+ initial_package = self._create_package_in_location(
+ self.shelf1,
+ [
+ self.PackageContent(self.product_a, 10, initial_lot_a),
+ self.PackageContent(self.product_b, 10, initial_lot_b),
+ ],
+ )
+
+ # create and reserve our transfer using the initial package
+ picking = self._create_picking_with_package_level(initial_package)
+ batch = self._create_batch_with_pickings(picking)
+ self._simulate_batch_selected(batch, fill_stock=False)
+
+ lines = picking.move_line_ids
+ package_level = lines.mapped("package_level_id")
+
+ # create a second package with the same content, which will be used
+ # as replacement
+ new_lot_a = self._create_lot(self.product_a)
+ new_lot_b = self._create_lot(self.product_b)
+ new_package = self._create_package_in_location(
+ self.shelf1,
+ [
+ self.PackageContent(self.product_a, 10, new_lot_a),
+ self.PackageContent(self.product_b, 10, new_lot_b),
+ ],
+ )
+ # changing the package of the first line will change all of them
+ self._test_change_pack_lot(
+ lines[0],
+ new_package.name,
+ success=True,
+ message={
+ "message_type": "info",
+ "body": "Package {} replaced by package {}".format(
+ initial_package.name, new_package.name
+ ),
+ },
+ )
+
+ self.assertRecordValues(
+ lines,
+ [
+ {
+ "package_id": new_package.id,
+ "result_package_id": new_package.id,
+ "lot_id": new_lot_a.id,
+ },
+ {
+ "package_id": new_package.id,
+ "result_package_id": new_package.id,
+ "lot_id": new_lot_b.id,
+ },
+ ],
+ )
+
+ self.assertRecordValues(package_level, [{"package_id": new_package.id}])
+ # check that reservations have been updated
+ for line in lines:
+ self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
+ self.assert_quant_reserved_qty(
+ line, lambda: line.product_qty, package=new_package
+ )
+
+ def test_change_pack_lot_change_pack_steal_from_other_move_line(self):
+ # create 2 picking, each with its own package
+ package1 = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)],
+ )
+ picking1 = self._create_picking_with_package_level(package1)
+ self.assertEqual(picking1.move_line_ids.package_id, package1)
+
+ package2 = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)],
+ )
+ picking2 = self._create_picking_with_package_level(package2)
+ self.assertEqual(picking2.move_line_ids.package_id, package2)
+
+ batch = self._create_batch_with_pickings(picking1 + picking2)
+ self._simulate_batch_selected(batch, fill_stock=False)
+
+ line = picking1.move_line_ids
+ # We "steal" package2 for the picking1
+ self._test_change_pack_lot(
+ line,
+ package2.name,
+ success=True,
+ message={
+ "message_type": "info",
+ "body": "Package {} replaced by package {}".format(
+ package1.name, package2.name
+ ),
+ },
+ )
+
+ self.assertRecordValues(
+ picking1.move_line_ids,
+ [
+ {
+ "package_id": package2.id,
+ "result_package_id": package2.id,
+ "state": "assigned",
+ }
+ ],
+ )
+ self.assertRecordValues(
+ picking2.move_line_ids,
+ [
+ {
+ "package_id": package1.id,
+ "result_package_id": package1.id,
+ "state": "assigned",
+ }
+ ],
+ )
+ self.assertRecordValues(
+ picking1.package_level_ids,
+ [{"package_id": package2.id, "state": "assigned"}],
+ )
+ self.assertRecordValues(
+ picking2.package_level_ids,
+ [{"package_id": package1.id, "state": "assigned"}],
+ )
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(
+ picking1.move_line_ids,
+ lambda: picking1.move_line_ids.product_qty,
+ package=package2,
+ )
+ self.assert_quant_reserved_qty(
+ picking2.move_line_ids,
+ lambda: picking2.move_line_ids.product_qty,
+ package=package1,
+ )
From 633c7bf50fa08bec0c97444aab22cec4b138fa5a Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 14 May 2020 08:11:33 +0200
Subject: [PATCH 215/940] cluster picking: fix flaky test on skip_line
---
shopfloor/services/cluster_picking.py | 4 ++--
shopfloor/tests/test_cluster_picking_skip.py | 12 ++++++++++--
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 6c7d980bd1..2d288feca7 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -771,9 +771,9 @@ def skip_line(self, move_line_id):
)
# flag as postponed
move_line.shopfloor_postponed = True
- return self._response_for_skip_line(move_line)
+ return self._pick_after_skip_line(move_line)
- def _response_for_skip_line(self, move_line):
+ def _pick_after_skip_line(self, move_line):
batch = move_line.picking_id.batch_id
return self._pick_next_line(batch)
diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py
index a2d83d1a6c..2e78a3acc2 100644
--- a/shopfloor/tests/test_cluster_picking_skip.py
+++ b/shopfloor/tests/test_cluster_picking_skip.py
@@ -35,8 +35,16 @@ def _skip_line(self, line, next_line=None):
def test_skip_line(self):
self._simulate_batch_selected(self.batch, in_package=True)
- lines = self.batch.picking_ids.move_line_ids
- # 1st line, next is 2nd
+ lines = self.batch.picking_ids.move_line_ids.sorted(
+ lambda line: (
+ line.location_id,
+ line.shopfloor_postponed,
+ line.move_id.sequence,
+ line.move_id.id,
+ line.id,
+ )
+ )
+
self.assertFalse(lines[0].shopfloor_postponed)
self._skip_line(lines[0], lines[1])
self.assertTrue(lines[0].shopfloor_postponed)
From 331a2fdc1225d15bf514ab4688f4ea4be74f246b Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 19 May 2020 16:38:09 +0200
Subject: [PATCH 216/940] backend: adapt data details
---
shopfloor/actions/data.py | 28 +++++++----
shopfloor/actions/data_detail.py | 2 +-
shopfloor/services/schema.py | 52 +++++++++++++++++----
shopfloor/services/schema_detail.py | 26 +----------
shopfloor/tests/common.py | 4 ++
shopfloor/tests/test_actions_data.py | 14 ++----
shopfloor/tests/test_actions_data_detail.py | 38 +++++++--------
7 files changed, 90 insertions(+), 74 deletions(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 977550d2ac..0d987e0da0 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -58,13 +58,16 @@ def _picking_parser(self):
"total_weight:weight",
]
- def package(self, record, picking=None, **kw):
+ def package(self, record, picking=None, with_packaging=False, **kw):
"""Return data for a stock.quant.package
If a picking is given, it will include the number of lines of the package
for the picking.
"""
- data = self._jsonify(record, self._package_parser, **kw)
+ parser = self._package_parser
+ if with_packaging:
+ parser += self._package_packaging_parser
+ data = self._jsonify(record, parser, **kw)
# handle special cases
if data and picking:
# TODO: exclude canceled and done?
@@ -73,7 +76,7 @@ def package(self, record, picking=None, **kw):
return data
def packages(self, records, picking=None, **kw):
- return [self.package(rec, picking=picking) for rec in records]
+ return [self.package(rec, picking=picking, **kw) for rec in records]
@property
def _package_parser(self):
@@ -81,6 +84,11 @@ def _package_parser(self):
"id",
"name",
"pack_weight:weight",
+ ]
+
+ @property
+ def _package_packaging_parser(self):
+ return [
("product_packaging_id:packaging", self._packaging_parser),
]
@@ -112,16 +120,18 @@ def move_line(self, record, **kw):
{
# cannot use sub-parser here
# because result might depend on picking
- "package_src": self.package(record.package_id, record.picking_id),
+ "package_src": self.package(
+ record.package_id, record.picking_id, **kw
+ ),
"package_dest": self.package(
- record.result_package_id, record.picking_id,
+ record.result_package_id, record.picking_id, **kw
),
}
)
return data
def move_lines(self, records, **kw):
- return [self.move_line(rec) for rec in records]
+ return [self.move_line(rec, **kw) for rec in records]
@property
def _move_line_parser(self):
@@ -145,14 +155,14 @@ def products(self, record, **kw):
def _product_parser(self):
return ["id", "name", "display_name", "default_code", "barcode"]
- def picking_batch(self, record, with_pickings=True, **kw):
+ def picking_batch(self, record, with_pickings=False, **kw):
parser = self._picking_batch_parser
if with_pickings:
parser.append(("picking_ids:pickings", self._picking_parser))
return self._jsonify(record, parser, **kw)
- def picking_batches(self, record, **kw):
- return self.picking_batch(record, multi=True)
+ def picking_batches(self, record, with_pickings=False, **kw):
+ return self.picking_batch(record, with_pickings=with_pickings, multi=True)
@property
def _picking_batch_parser(self):
diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py
index 2cd588e7f6..b118e86e93 100644
--- a/shopfloor/actions/data_detail.py
+++ b/shopfloor/actions/data_detail.py
@@ -53,7 +53,7 @@ def _picking_detail_parser(self):
def package_detail(self, record, picking=None, **kw):
# Define a new method to not overload the base one which is used in many places
- data = self.package(record, picking=picking, **kw)
+ data = self.package(record, picking=picking, with_packaging=True, **kw)
data.update(self._jsonify(record, self._package_detail_parser, **kw))
return data
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 224db160ad..d97dbf36b6 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -14,12 +14,46 @@ class BaseShopfloorSchemaResponse(Component):
_usage = "schema"
_is_rest_service_component = False
+ def _schema_list_of(self, schema, **kw):
+ return {
+ "type": "list",
+ "nullable": True,
+ "required": False,
+ "schema": {"type": "dict", "schema": schema},
+ }
+
+ def _simple_record(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ }
+
+ def _schema_dict_of(self, schema, **kw):
+ schema = {
+ "type": "dict",
+ "nullable": True,
+ "required": False,
+ "schema": schema,
+ }
+ schema.update(kw)
+ return schema
+
+ def _schema_search_results_of(self, schema, **kw):
+ return {
+ "size": {"required": True, "type": "integer"},
+ "records": {
+ "type": "list",
+ "required": True,
+ "schema": {"type": "dict", "schema": schema},
+ },
+ }
+
def picking(self):
return {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
- "origin": {"type": "string", "nullable": True, "required": True},
- "note": {"type": "string", "nullable": True, "required": True},
+ "origin": {"type": "string", "nullable": True, "required": False},
+ "note": {"type": "string", "nullable": True, "required": False},
"move_line_count": {"type": "integer", "nullable": True, "required": True},
"weight": {"required": True, "nullable": True, "type": "float"},
"partner": {
@@ -53,7 +87,7 @@ def move_line(self):
},
"package_dest": {
"type": "dict",
- "required": True,
+ "required": False,
"nullable": True,
"schema": self.package(),
},
@@ -78,19 +112,21 @@ def product(self):
"barcode": {"type": "string", "nullable": True, "required": False},
}
- def package(self):
- return {
+ def package(self, with_packaging=False):
+ schema = {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"weight": {"required": True, "nullable": True, "type": "float"},
"move_line_count": {"required": False, "nullable": True, "type": "integer"},
- "packaging": {
+ }
+ if with_packaging:
+ schema["packaging"] = {
"type": "dict",
"required": True,
"nullable": True,
"schema": self.packaging(),
- },
- }
+ }
+ return schema
def lot(self):
return {
diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py
index b28f069e7e..7736db7a8d 100644
--- a/shopfloor/services/schema_detail.py
+++ b/shopfloor/services/schema_detail.py
@@ -12,30 +12,6 @@ class ShopfloorSchemaDetailResponse(Component):
_name = "base.shopfloor.schemas.detail"
_usage = "schema_detail"
- def _schema_list_of(self, schema, **kw):
- return {
- "type": "list",
- "nullable": True,
- "required": False,
- "schema": {"type": "dict", "schema": schema},
- }
-
- def _simple_record(self):
- return {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- }
-
- def _schema_dict_of(self, schema, **kw):
- schema = {
- "type": "dict",
- "nullable": True,
- "required": False,
- "schema": schema,
- }
- schema.update(kw)
- return schema
-
def location_detail(self):
schema = self.location()
schema.update(
@@ -68,7 +44,7 @@ def picking_detail(self):
return schema
def package_detail(self):
- schema = self.package()
+ schema = self.package(with_packaging=True)
schema.update(
{
"pickings": self._schema_list_of(self.picking()),
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 966207b53f..1cacd73b78 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -67,6 +67,10 @@ def setUpClass(cls):
cls.setUpComponent()
cls.setUpClassVars()
cls.setUpClassBaseData()
+ with cls.work_on_actions(cls) as work:
+ cls.data = work.component(usage="data")
+ with cls.work_on_actions(cls) as work:
+ cls.data_detail = work.component(usage="data_detail")
@classmethod
def setUpClassVars(cls):
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index b25c08cd0b..4f44ad4dcd 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -66,8 +66,6 @@ def _expected_product(self, record, **kw):
class ActionsDataCase(ActionsDataCaseBase):
def setUp(self):
super().setUp()
- with self.work_on_actions() as work:
- self.data = work.component(usage="data")
with self.work_on_services() as work:
self.schema = work.component(usage="schema")
@@ -104,8 +102,8 @@ def test_data_lot(self):
def test_data_package(self):
package = self.move_a.move_line_ids.package_id
package.product_packaging_id = self.packaging.id
- data = self.data.package(package, picking=self.picking)
- self.assert_schema(self.schema.package(), data)
+ data = self.data.package(package, picking=self.picking, with_packaging=True)
+ self.assert_schema(self.schema.package(with_packaging=True), data)
expected = {
"id": package.id,
"name": package.name,
@@ -148,17 +146,15 @@ def test_data_move_line_package(self):
"id": move_line.package_id.id,
"name": move_line.package_id.name,
"move_line_count": 1,
- "packaging": None,
# TODO
- "weight": 0,
+ "weight": 0.0,
},
"package_dest": {
"id": result_package.id,
"name": result_package.name,
"move_line_count": 0,
- "packaging": self.data.packaging(self.packaging),
# TODO
- "weight": 0,
+ "weight": 0.0,
},
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
@@ -205,7 +201,6 @@ def test_data_move_line_package_lot(self):
"id": move_line.package_id.id,
"name": move_line.package_id.name,
"move_line_count": 1,
- "packaging": None,
# TODO
"weight": 0,
},
@@ -213,7 +208,6 @@ def test_data_move_line_package_lot(self):
"id": move_line.result_package_id.id,
"name": move_line.result_package_id.name,
"move_line_count": 1,
- "packaging": None,
# TODO
"weight": 0,
},
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index cbd2e71bcd..c208d522c9 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -29,8 +29,6 @@ def setUpClass(cls):
def setUp(self):
super().setUp()
- with self.work_on_actions() as work:
- self.data = work.component(usage="data_detail")
with self.work_on_services() as work:
self.schema = work.component(usage="schema_detail")
@@ -39,7 +37,9 @@ def _expected_location_detail(self, record, **kw):
**self._expected_location(record),
**{
"complete_name": record.complete_name,
- "reserved_move_lines": self.data.move_lines(kw.get("move_lines", [])),
+ "reserved_move_lines": self.data_detail.move_lines(
+ kw.get("move_lines", [])
+ ),
}
)
@@ -82,7 +82,7 @@ def _expected_product_detail(self, record, **kw):
class ActionsDataDetailCase(ActionsDataDetailCaseBase):
def test_data_location(self):
location = self.stock_location
- data = self.data.location_detail(location)
+ data = self.data_detail.location_detail(location)
self.assert_schema(self.schema.location_detail(), data)
move_lines = self.env["stock.move.line"].search(
[
@@ -96,7 +96,7 @@ def test_data_location(self):
)
def test_data_packaging(self):
- data = self.data.packaging(self.packaging)
+ data = self.data_detail.packaging(self.packaging)
self.assert_schema(self.schema.packaging(), data)
expected = {"id": self.packaging.id, "name": self.packaging.name}
self.assertDictEqual(data, expected)
@@ -111,7 +111,7 @@ def test_data_lot(self):
"life_date": "2020-05-31",
}
)
- data = self.data.lot_detail(lot)
+ data = self.data_detail.lot_detail(lot)
self.assert_schema(self.schema.lot_detail(), data)
expected = {
@@ -130,7 +130,7 @@ def test_data_package(self):
package.product_packaging_id = self.packaging.id
package.package_storage_type_id = self.storage_type_pallet
# package.invalidate_cache()
- data = self.data.package_detail(package, picking=self.picking)
+ data = self.data_detail.package_detail(package, picking=self.picking)
self.assert_schema(self.schema.package_detail(), data)
lines = self.env["stock.move.line"].search(
@@ -141,10 +141,10 @@ def test_data_package(self):
"id": package.id,
"name": package.name,
"move_line_count": 1,
- "packaging": self.data.packaging(package.product_packaging_id),
+ "packaging": self.data_detail.packaging(package.product_packaging_id),
"weight": 0,
- "pickings": self.data.pickings(pickings),
- "move_lines": self.data.move_lines(lines),
+ "pickings": self.data_detail.pickings(pickings),
+ "move_lines": self.data_detail.move_lines(lines),
"storage_type": {
"id": self.storage_type_pallet.id,
"name": self.storage_type_pallet.name,
@@ -164,7 +164,7 @@ def test_data_picking(self):
}
)
picking.move_lines.write({"date_expected": "2020-05-13"})
- data = self.data.picking_detail(picking)
+ data = self.data_detail.picking_detail(picking)
self.assert_schema(self.schema.picking_detail(), data)
expected = {
"id": picking.id,
@@ -180,7 +180,7 @@ def test_data_picking(self):
"name": picking.picking_type_id.name,
},
"carrier": {"id": carrier.id, "name": carrier.name},
- "lines": self.data.move_lines(picking.move_line_ids),
+ "lines": self.data_detail.move_lines(picking.move_line_ids),
}
self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13")
self.maxDiff = None
@@ -192,7 +192,7 @@ def test_data_move_line_package(self):
{"product_packaging_id": self.packaging.id}
)
move_line.write({"qty_done": 3.0, "result_package_id": result_package.id})
- data = self.data.move_line(move_line)
+ data = self.data_detail.move_line(move_line)
self.assert_schema(self.schema.move_line(), data)
product = self.product_a.with_context(location=move_line.location_id.id)
expected = {
@@ -205,14 +205,12 @@ def test_data_move_line_package(self):
"id": move_line.package_id.id,
"name": move_line.package_id.name,
"move_line_count": 1,
- "packaging": None,
"weight": 0.0,
},
"package_dest": {
"id": result_package.id,
"name": result_package.name,
"move_line_count": 0,
- "packaging": self.data.packaging(self.packaging),
"weight": 0.0,
},
"location_src": self._expected_location(move_line.location_id),
@@ -222,7 +220,7 @@ def test_data_move_line_package(self):
def test_data_move_line_lot(self):
move_line = self.move_b.move_line_ids
- data = self.data.move_line(move_line)
+ data = self.data_detail.move_line(move_line)
self.assert_schema(self.schema.move_line(), data)
product = self.product_b.with_context(location=move_line.location_id.id)
expected = {
@@ -245,7 +243,7 @@ def test_data_move_line_lot(self):
def test_data_move_line_package_lot(self):
self.maxDiff = None
move_line = self.move_c.move_line_ids
- data = self.data.move_line(move_line)
+ data = self.data_detail.move_line(move_line)
self.assert_schema(self.schema.move_line(), data)
product = self.product_c.with_context(location=move_line.location_id.id)
expected = {
@@ -262,14 +260,12 @@ def test_data_move_line_package_lot(self):
"id": move_line.package_id.id,
"name": move_line.package_id.name,
"move_line_count": 1,
- "packaging": None,
"weight": 0.0,
},
"package_dest": {
"id": move_line.result_package_id.id,
"name": move_line.result_package_id.name,
"move_line_count": 1,
- "packaging": None,
"weight": 0.0,
},
"location_src": self._expected_location(move_line.location_id),
@@ -279,7 +275,7 @@ def test_data_move_line_package_lot(self):
def test_data_move_line_raw(self):
move_line = self.move_d.move_line_ids
- data = self.data.move_line(move_line)
+ data = self.data_detail.move_line(move_line)
self.assert_schema(self.schema.move_line(), data)
product = self.product_d.with_context(location=move_line.location_id.id)
expected = {
@@ -323,7 +319,7 @@ def test_product(self):
"product_code": "SUPP2",
}
)
- data = self.data.product_detail(product)
+ data = self.data_detail.product_detail(product)
self.assert_schema(self.schema.product_detail(), data)
expected = self._expected_product_detail(product, full=True)
self.assertDictEqual(data, expected)
From 918f33b1e8a2ea384eb49a0625f2b4cfea9a681e Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 19 May 2020 16:44:42 +0200
Subject: [PATCH 217/940] backend: unify data conversion all around
---
shopfloor/services/checkout.py | 46 ++--
shopfloor/services/cluster_picking.py | 226 ++----------------
shopfloor/services/delivery.py | 6 +-
shopfloor/services/single_pack_transfer.py | 2 +-
shopfloor/services/validator.py | 1 +
shopfloor/tests/test_checkout_base.py | 8 +-
shopfloor/tests/test_cluster_picking_base.py | 58 ++---
shopfloor/tests/test_cluster_picking_scan.py | 18 +-
.../tests/test_cluster_picking_select.py | 163 ++-----------
.../tests/test_cluster_picking_stock_issue.py | 10 +-
.../tests/test_cluster_picking_unload.py | 197 ++++-----------
shopfloor/tests/test_scan_anything.py | 10 +-
12 files changed, 163 insertions(+), 582 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 536e2465a3..98db69b2db 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -152,11 +152,15 @@ def _response_for_summary(self, picking, need_confirm=False, message=None):
def _response_for_select_document(self, message=None):
return self._response(next_state="select_document", message=message)
+ def _data_for_move_lines(self, lines):
+ data_struct = self.actions_for("data")
+ return data_struct.move_lines(lines)
+
def _data_for_stock_picking(self, picking, done=False):
data_struct = self.actions_for("data")
data = data_struct.picking(picking)
line_picker = self._lines_checkout_done if done else self._lines_to_pack
- data.update({"move_lines": data_struct.move_lines(line_picker(picking))})
+ data.update({"move_lines": self._data_for_move_lines(line_picker(picking))})
return data
def _lines_checkout_done(self, picking):
@@ -221,7 +225,7 @@ def _response_for_select_package(self, lines, message=None):
return self._response(
next_state="select_package",
data={
- "selected_move_lines": data_struct.move_lines(lines.sorted()),
+ "selected_move_lines": self._data_for_move_lines(lines.sorted()),
"picking": data_struct.picking(picking),
},
message=message,
@@ -736,14 +740,16 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None):
)
data_struct = self.actions_for("data")
picking_data = data_struct.picking(picking)
- packages_data = data_struct.packages(packages.sorted(), picking=picking)
+ packages_data = data_struct.packages(
+ packages.sorted(), picking=picking, with_packaging=True
+ )
data_struct = self.actions_for("data")
return self._response(
next_state="select_dest_package",
data={
"picking": picking_data,
"packages": packages_data,
- "selected_move_lines": data_struct.move_lines(move_lines.sorted()),
+ "selected_move_lines": self._data_for_move_lines(move_lines.sorted()),
},
message=message,
)
@@ -850,7 +856,9 @@ def _response_for_change_packaging(self, picking, package, packaging_list):
next_state="change_packaging",
data={
"picking": data_struct.picking(picking),
- "package": data_struct.package(package, picking=picking),
+ "package": data_struct.package(
+ package, picking=picking, with_packaging=True
+ ),
"packagings": data_struct.packagings(packaging_list.sorted()),
},
)
@@ -1164,12 +1172,12 @@ def _states(self):
@property
def _schema_stock_picking_details(self):
- schema = self.schemas().picking()
+ schema = self.schemas.picking()
schema.update(
{
"move_lines": {
"type": "list",
- "schema": {"type": "dict", "schema": self.schemas().move_line()},
+ "schema": {"type": "dict", "schema": self.schemas.move_line()},
},
}
)
@@ -1186,7 +1194,7 @@ def _schema_selection_list(self):
return {
"pickings": {
"type": "list",
- "schema": {"type": "dict", "schema": self.schemas().picking()},
+ "schema": {"type": "dict", "schema": self.schemas.picking()},
}
}
@@ -1195,23 +1203,29 @@ def _schema_select_package(self):
return {
"selected_move_lines": {
"type": "list",
- "schema": {"type": "dict", "schema": self.schemas().move_line()},
+ "schema": {"type": "dict", "schema": self.schemas.move_line()},
},
"packages": {
"type": "list",
- "schema": {"type": "dict", "schema": self.schemas().package()},
+ "schema": {
+ "type": "dict",
+ "schema": self.schemas.package(with_packaging=True),
+ },
},
- "picking": {"type": "dict", "schema": self.schemas().picking()},
+ "picking": {"type": "dict", "schema": self.schemas.picking()},
}
@property
def _schema_select_packaging(self):
return {
- "picking": {"type": "dict", "schema": self.schemas().picking()},
- "package": {"type": "dict", "schema": self.schemas().package()},
+ "picking": {"type": "dict", "schema": self.schemas.picking()},
+ "package": {
+ "type": "dict",
+ "schema": self.schemas.package(with_packaging=True),
+ },
"packagings": {
"type": "list",
- "schema": {"type": "dict", "schema": self.schemas().packaging()},
+ "schema": {"type": "dict", "schema": self.schemas.packaging()},
},
}
@@ -1220,9 +1234,9 @@ def _schema_selected_lines(self):
return {
"selected_move_lines": {
"type": "list",
- "schema": {"type": "dict", "schema": self.schemas().move_line()},
+ "schema": {"type": "dict", "schema": self.schemas.move_line()},
},
- "picking": {"type": "dict", "schema": self.schemas().picking()},
+ "picking": {"type": "dict", "schema": self.schemas.picking()},
}
def scan_document(self):
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 2d288feca7..c0ba42fc97 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -368,19 +368,23 @@ def _next_line_for_pick(self, picking_batch):
def _response_batch_does_not_exist(self):
return self._response_for_start(message=self.msg_store.record_not_found())
- def _data_move_line(self, line):
+ def _data_move_line(self, line, **kw):
picking = line.picking_id
batch = picking.batch_id
product = line.product_id
data = self.data_struct.move_line(line)
# additional values
- data.pop("package_dest", None)
+ # Ensure destination pack is never proposed on the frontend.
+ # This should happen only as proposal on `scan_destination`
+ # where we set the last used package.
+ data["package_dest"] = None
data["batch"] = self.data_struct.picking_batch(batch)
data["picking"] = self.data_struct.picking(picking)
data["postponed"] = line.shopfloor_postponed
data["product"]["qty_available"] = product.with_context(
location=line.location_id.id
).qty_available
+ data.update(kw)
return data
def unassign(self, picking_batch_id):
@@ -686,7 +690,6 @@ def _data_for_unload_single(self, batch, package):
line = fields.first(
package.planned_move_line_ids.filtered(self._filter_for_unload)
)
- # TODO disambiguate "id" everywhere? (id -> picking_batch_id)
data = self.data_struct.picking_batch(batch)
data.update(
{
@@ -1541,221 +1544,40 @@ def unload_scan_destination(self):
}
)
- # TODO single class for sharing schemas between services
@property
def _schema_for_batch_details(self):
- return {
- # TODO full name instead of id? or always wrap in batch/move_line?
- # id is a stock.picking.batch
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "pickings": {
- "type": "list",
- "required": True,
- "schema": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "move_line_count": {"required": True, "type": "integer"},
- "weight": {
- "type": "float",
- "nullable": False,
- "required": True,
- },
- "origin": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- "partner": {
- "type": "dict",
- "required": False,
- "nullable": True,
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
- },
- },
- },
- },
- }
+ schema = self.schemas.picking_batch()
+ schema["pickings"] = self.schemas._schema_list_of(self.schemas.picking())
+ return schema
@property
def _schema_for_single_line_details(self):
- return {
- # id is a stock.move.line
- "id": {"required": True, "type": "integer"},
- "quantity": {"type": "float", "required": True},
- "postponed": {"type": "boolean", "required": False},
- "picking": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "origin": {"type": "string", "nullable": True, "required": True},
- "note": {"type": "string", "nullable": True, "required": True},
- "partner": {
- "type": "dict",
- "required": False,
- "nullable": True,
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- },
- },
- },
- },
- "batch": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "product": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "display_name": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- "default_code": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
- "qty_available": {
- "type": "float",
- "nullable": False,
- "required": True,
- },
- },
- },
- "lot": {
- "type": "dict",
- "required": False,
- "nullable": True,
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "ref": {"type": "string", "nullable": True, "required": True},
- },
- },
- # TODO share parts of the schema?
- "location_src": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "location_dest": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "package_src": {
- "type": "dict",
- "required": False,
- "nullable": True,
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "package_dest": {
- "type": "dict",
- "required": False,
- "nullable": True,
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- }
+ schema = self.schemas.move_line()
+ schema["picking"] = self.schemas._schema_dict_of(self.schemas.picking())
+ schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch())
+ return schema
@property
def _schema_for_unload_all(self):
- return {
- # stock.batch.picking
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "location_dest": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- }
+ schema = self.schemas.picking_batch()
+ schema["location_dest"] = self.schemas._schema_dict_of(self.schemas.location())
+ return schema
@property
def _schema_for_unload_single(self):
- return {
- # stock.batch.picking
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- "package": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- "location_dest": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- }
+ schema = self.schemas.picking_batch()
+ schema["package"] = self.schemas._schema_dict_of(self.schemas.package())
+ schema["location_dest"] = self.schemas._schema_dict_of(self.schemas.location())
+ return schema
@property
def _schema_for_zero_check(self):
- return {
- # stock.move.line
+ schema = {
"id": {"required": True, "type": "integer"},
- "location_src": {
- "type": "dict",
- "schema": {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
- },
- },
- }
-
- @property
- def _schema_for_completion_info(self):
- return {
- # stock.picking.batch
- "id": {"required": True, "type": "integer"},
- "picking_done": {"type": "string", "nullable": False, "required": True},
- "picking_next": {"type": "string", "nullable": False, "required": True},
}
+ schema["location_src"] = self.schemas._schema_dict_of(self.schemas.location())
+ return schema
@property
def _schema_for_batch_selection(self):
- return {
- "size": {"required": True, "type": "integer"},
- "records": {
- "type": "list",
- "required": True,
- "schema": {"type": "dict", "schema": self.schemas().picking_batch()},
- },
- }
+ return self.schemas._schema_search_results_of(self.schemas.picking_batch())
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 257910746f..9c59f4df34 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -202,12 +202,12 @@ def _states(self):
@property
def _schema_deliver(self):
- schema = self.schemas().picking()
+ schema = self.schemas.picking()
schema.update(
{
"move_lines": {
"type": "list",
- "schema": {"type": "dict", "schema": self.schemas().move_line()},
+ "schema": {"type": "dict", "schema": self.schemas.move_line()},
}
}
)
@@ -218,7 +218,7 @@ def _schema_selection_list(self):
return {
"pickings": {
"type": "list",
- "schema": {"type": "dict", "schema": self.schemas().picking()},
+ "schema": {"type": "dict", "schema": self.schemas.picking()},
}
}
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index fc7e9465fe..adecaf1aed 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -264,4 +264,4 @@ def validate(self):
@property
def _schema_for_package_level_details(self):
- return self.schemas().package_level()
+ return self.schemas.package_level()
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
index dd9a2d1fb3..c484c1514b 100644
--- a/shopfloor/services/validator.py
+++ b/shopfloor/services/validator.py
@@ -66,6 +66,7 @@ def _states(self):
"""
return {}
+ @property
def schemas(self):
return self.component(usage="schema")
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index ce6a969d6b..6dc4d8d885 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -21,13 +21,13 @@ def _stock_picking_data(self, picking, **kw):
# we test the methods that structure data in test_actions_data.py
def _picking_summary_data(self, picking):
- return self.service.actions_for("data").picking(picking)
+ return self.data.picking(picking)
def _move_line_data(self, move_line):
- return self.service.actions_for("data").move_line(move_line)
+ return self.data.move_line(move_line)
def _package_data(self, package, picking):
- return self.service.actions_for("data").package(package, picking=picking)
+ return self.data.package(package, picking=picking, with_packaging=True)
def _packaging_data(self, packaging):
- return self.service.actions_for("data").packaging(packaging)
+ return self.data.packaging(packaging)
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index 8ec69b8838..bd551f0c80 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -37,47 +37,22 @@ def _simulate_batch_selected(
pickings.action_assign()
batches.write({"state": "in_progress", "user_id": cls.env.uid})
- def _line_data(self, move_line, qty=None):
+ def _line_data(self, move_line, qty=None, package_dest=False):
picking = move_line.picking_id
- batch = picking.batch_id
# A package exists on the move line, because the quant created
# by ``_simulate_batch_selected`` has a package.
- package = move_line.package_id
- lot = move_line.lot_id
- return {
- "id": move_line.id,
- "quantity": qty or move_line.product_uom_qty,
- "postponed": move_line.shopfloor_postponed,
- "location_dest": {
- "id": move_line.location_dest_id.id,
- "name": move_line.location_dest_id.name,
- },
- "location_src": {
- "id": move_line.location_id.id,
- "name": move_line.location_id.name,
- },
- "picking": {
- "id": picking.id,
- "name": picking.name,
- "note": None,
- "origin": picking.origin,
- "partner": {"id": self.customer.id, "name": self.customer.name},
- },
- "batch": {"id": batch.id, "name": batch.name},
- "product": {
- "default_code": move_line.product_id.default_code,
- "display_name": move_line.product_id.display_name,
- "id": move_line.product_id.id,
- "name": move_line.product_id.name,
- "qty_available": move_line.product_id.qty_available,
- },
- "lot": {"id": lot.id, "name": lot.name, "ref": lot.ref or None}
- if lot
- else None,
- "package_src": {"id": package.id, "name": package.name}
- if package
- else None,
- }
+ data = self.data.move_line(move_line)
+ if not package_dest:
+ data["package_dest"] = None
+ if qty:
+ data["quantity"] = qty
+ data.update(
+ {
+ "batch": self.data.picking_batch(picking.batch_id),
+ "picking": self.data.picking(picking),
+ }
+ )
+ return data
@classmethod
def _set_dest_package_and_done(cls, move_lines, dest_package):
@@ -87,6 +62,13 @@ def _set_dest_package_and_done(cls, move_lines, dest_package):
{"qty_done": line.product_uom_qty, "result_package_id": dest_package.id}
)
+ def _data_for_batch(self, batch, location, pack=None):
+ data = self.data.picking_batch(batch)
+ data["location_dest"] = self.data.location(location)
+ if pack:
+ data["package"] = self.data.package(pack)
+ return data
+
class ClusterPickingLineCommonCase(ClusterPickingCommonCase):
@classmethod
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 9cdd4a2ce6..3f54ab3984 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -343,18 +343,12 @@ def test_scan_destination_pack_ok_last_line(self):
self.assertRecordValues(
line, [{"qty_done": qty_done, "result_package_id": self.bin2.id}]
)
+ data = self._data_for_batch(self.batch, self.packing_location)
self.assert_response(
response,
# they reach the same destination so next state unload_all
next_state="unload_all",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "location_dest": {
- "id": self.packing_location.id,
- "name": self.packing_location.name,
- },
- },
+ data=data,
)
def test_scan_destination_pack_not_empty_same_picking(self):
@@ -510,13 +504,7 @@ def test_scan_destination_pack_zero_check(self):
self.assert_response(
response,
next_state="zero_check",
- data={
- "id": line.id,
- "location_src": {
- "id": line.location_id.id,
- "name": line.location_id.name,
- },
- },
+ data={"id": line.id, "location_src": self.data.location(line.location_id)},
)
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index 14c5285403..79e51fc58d 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -51,27 +51,9 @@ def test_find_batch_in_progress_current_user(self):
# we expect to find batch 3 as it's assigned to the current
# user and in progress (first priority)
+ data = self.data.picking_batch(self.batch3, with_pickings=True)
self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch3.id,
- "name": self.batch3.name,
- "pickings": [
- {
- "id": self.batch3.picking_ids.id,
- "name": self.batch3.picking_ids.name,
- "move_line_count": len(self.batch3.picking_ids.move_line_ids),
- "origin": self.batch3.picking_ids.origin,
- # quantity of products (3) * weight of product (2)
- "weight": 6.0,
- "partner": {
- "id": self.batch3.picking_ids.partner_id.id,
- "name": self.batch3.picking_ids.partner_id.name,
- },
- }
- ],
- },
+ response, next_state="confirm_start", data=data,
)
def test_find_batch_assigned(self):
@@ -89,26 +71,9 @@ def test_find_batch_assigned(self):
self.assertEqual(self.batch2.state, "in_progress")
# we expect to find batch 2 as it's assigned to the current user
+ data = self.data.picking_batch(self.batch2, with_pickings=True)
self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch2.id,
- "name": self.batch2.name,
- "pickings": [
- {
- "id": self.batch2.picking_ids.id,
- "name": self.batch2.picking_ids.name,
- "move_line_count": len(self.batch2.picking_ids.move_line_ids),
- "origin": self.batch2.picking_ids.origin,
- "weight": 6.0,
- "partner": {
- "id": self.batch2.picking_ids.partner_id.id,
- "name": self.batch2.picking_ids.partner_id.name,
- },
- }
- ],
- },
+ response, next_state="confirm_start", data=data,
)
def test_find_batch_unassigned_draft(self):
@@ -125,26 +90,9 @@ def test_find_batch_unassigned_draft(self):
# we expect to find batch 2 as it's the first one with all pickings
# available
+ data = self.data.picking_batch(self.batch2, with_pickings=True)
self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch2.id,
- "name": self.batch2.name,
- "pickings": [
- {
- "id": self.batch2.picking_ids.id,
- "name": self.batch2.picking_ids.name,
- "move_line_count": len(self.batch2.picking_ids.move_line_ids),
- "origin": self.batch2.picking_ids.origin,
- "weight": 6.0,
- "partner": {
- "id": self.batch2.picking_ids.partner_id.id,
- "name": self.batch2.picking_ids.partner_id.name,
- },
- }
- ],
- },
+ response, next_state="confirm_start", data=data,
)
def test_find_batch_not_found(self):
@@ -152,7 +100,6 @@ def test_find_batch_not_found(self):
# No batch match the rules to work on them, because
# their pickings are not available
response = self.service.dispatch("find_batch")
-
self.assert_response(
response,
next_state="start",
@@ -185,25 +132,7 @@ def test_list_batch(self):
next_state="manual_selection",
data={
"size": 2,
- "records": [
- {
- "id": self.batch1.id,
- "name": self.batch1.name,
- "picking_count": 1,
- "move_line_count": 1,
- "weight": 6.0,
- },
- # batch 2 is excluded because assigned to someone else
- {
- "id": self.batch3.id,
- "name": self.batch3.name,
- "picking_count": 1,
- "move_line_count": 1,
- "weight": 6.0,
- },
- # batch 4 is excluded because not all of its pickings are
- # assigned
- ],
+ "records": self.data.picking_batches(self.batch1 + self.batch3),
},
)
@@ -215,16 +144,11 @@ def test_select_in_progress_assigned(self):
response = self.service.dispatch(
"select", params={"picking_batch_id": self.batch1.id}
)
+ data = self.data.picking_batch(self.batch1)
+ # we don't care in these tests, 'find_batch' tests them already
+ data["pickings"] = self.ANY
self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch1.id,
- "name": self.batch1.name,
- # we don't care in these tests, the 'find_batch' tests already
- # check this
- "pickings": self.ANY,
- },
+ response, next_state="confirm_start", data=data,
)
def test_select_draft_assigned(self):
@@ -238,16 +162,11 @@ def test_select_draft_assigned(self):
# The endpoint starts the batch and assign it to self
self.assertEqual(self.batch1.user_id, self.env.user)
self.assertEqual(self.batch1.state, "in_progress")
+ data = self.data.picking_batch(self.batch1)
+ # we don't care in these tests, 'find_batch' tests them already
+ data["pickings"] = self.ANY
self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch1.id,
- "name": self.batch1.name,
- # we don't care in these tests, the 'find_batch' tests already
- # check this
- "pickings": self.ANY,
- },
+ response, next_state="confirm_start", data=data,
)
def test_select_draft_unassigned(self):
@@ -260,16 +179,11 @@ def test_select_draft_unassigned(self):
# The endpoint starts the batch and assign it to self
self.assertEqual(self.batch1.user_id, self.env.user)
self.assertEqual(self.batch1.state, "in_progress")
+ data = self.data.picking_batch(self.batch1)
+ # we don't care in these tests, 'find_batch' tests them already
+ data["pickings"] = self.ANY
self.assert_response(
- response,
- next_state="confirm_start",
- data={
- "id": self.batch1.id,
- "name": self.batch1.name,
- # we don't care in these tests, the 'find_batch' tests already
- # check this
- "pickings": self.ANY,
- },
+ response, next_state="confirm_start", data=data,
)
def test_select_not_exists(self):
@@ -363,39 +277,12 @@ def test_confirm_start_ok(self):
response = self.service.dispatch(
"confirm_start", params={"picking_batch_id": self.batch.id}
)
+ data = self.data.move_line(first_move_line)
+ data["package_dest"] = None
+ data["picking"] = self.data.picking(picking)
+ data["batch"] = self.data.picking_batch(batch)
self.assert_response(
- response,
- data={
- "id": first_move_line.id,
- "quantity": 1.0,
- "postponed": False,
- "location_dest": {
- "id": first_move_line.location_dest_id.id,
- "name": first_move_line.location_dest_id.name,
- },
- "location_src": {
- "id": first_move_line.location_id.id,
- "name": first_move_line.location_id.name,
- },
- "picking": {
- "id": picking.id,
- "name": picking.name,
- "note": None,
- "origin": picking.origin,
- "partner": {"id": self.customer.id, "name": self.customer.name},
- },
- "batch": {"id": batch.id, "name": batch.name},
- "product": {
- "default_code": first_move_line.product_id.default_code,
- "display_name": first_move_line.product_id.display_name,
- "id": first_move_line.product_id.id,
- "name": first_move_line.product_id.name,
- "qty_available": first_move_line.product_id.qty_available,
- },
- "lot": None,
- "package_src": {"id": package.id, "name": package.name},
- },
- next_state="start_line",
+ response, data=data, next_state="start_line",
)
def test_confirm_start_not_exists(self):
@@ -433,3 +320,5 @@ def test_confirm_start_all_is_done(self):
next_state="start",
message={"body": "Batch Transfer complete", "message_type": "success"},
)
+
+ # TODO: add a test for lines sorting
diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py
index deaa8204d9..1c7c7b12fb 100644
--- a/shopfloor/tests/test_cluster_picking_stock_issue.py
+++ b/shopfloor/tests/test_cluster_picking_stock_issue.py
@@ -45,16 +45,8 @@ def _stock_issue(self, line, next_line_func=None):
self.assert_response(
response,
next_state="unload_all",
- data={
- "id": self.batch.id,
- "location_dest": {
- "id": self.packing_location.id,
- "name": self.packing_location.name,
- },
- "name": self.batch.name,
- },
+ data=self._data_for_batch(self.batch, self.packing_location),
)
-
return response
def assert_location_qty_and_reserved(
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 1205ec10b9..9d43419d80 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -61,17 +61,10 @@ def test_prepare_unload_all_same_dest(self):
response = self.service.dispatch(
"prepare_unload", params={"picking_batch_id": self.batch.id}
)
+ location = self.packing_location
+ data = self._data_for_batch(self.batch, location)
self.assert_response(
- response,
- next_state="unload_all",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "location_dest": {
- "id": self.packing_location.id,
- "name": self.packing_location.name,
- },
- },
+ response, next_state="unload_all", data=data,
)
def test_prepare_unload_different_dest(self):
@@ -85,18 +78,10 @@ def test_prepare_unload_different_dest(self):
"prepare_unload", params={"picking_batch_id": self.batch.id}
)
first_line = move_lines[0]
+ location = first_line.location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
- response,
- next_state="unload_single",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dest": {
- "id": first_line.location_dest_id.id,
- "name": first_line.location_dest_id.name,
- },
- },
+ response, next_state="unload_single", data=data,
)
@@ -237,18 +222,10 @@ def test_set_destination_all_but_different_dest(self):
"barcode": self.packing_location.barcode,
},
)
+ location = move_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
- response,
- next_state="unload_single",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dest": {
- "id": move_lines[0].location_dest_id.id,
- "name": move_lines[0].location_dest_id.name,
- },
- },
+ response, next_state="unload_single", data=data,
)
def test_set_destination_all_error_location_not_found(self):
@@ -261,17 +238,12 @@ def test_set_destination_all_error_location_not_found(self):
"set_destination_all",
params={"picking_batch_id": self.batch.id, "barcode": "NOTFOUND"},
)
+ location = move_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location)
self.assert_response(
response,
next_state="unload_all",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "location_dest": {
- "id": move_lines[0].location_dest_id.id,
- "name": move_lines[0].location_dest_id.name,
- },
- },
+ data=data,
message={
"message_type": "error",
"body": "No location found for this barcode.",
@@ -295,17 +267,12 @@ def test_set_destination_all_error_location_invalid(self):
"barcode": self.dispatch_location.barcode,
},
)
+ location = move_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location)
self.assert_response(
response,
next_state="unload_all",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "location_dest": {
- "id": move_lines[0].location_dest_id.id,
- "name": move_lines[0].location_dest_id.name,
- },
- },
+ data=data,
message={"message_type": "error", "body": "You cannot place it here"},
)
@@ -322,17 +289,10 @@ def test_set_destination_all_need_confirmation(self):
"barcode": self.packing_b_location.barcode,
},
)
+ location = move_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location)
self.assert_response(
- response,
- next_state="confirm_unload_all",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "location_dest": {
- "id": move_lines[0].location_dest_id.id,
- "name": move_lines[0].location_dest_id.name,
- },
- },
+ response, next_state="confirm_unload_all", data=data,
)
def test_set_destination_all_with_confirmation(self):
@@ -391,19 +351,13 @@ def test_unload_split_ok(self):
response = self.service.dispatch(
"unload_split", params={"picking_batch_id": self.batch.id}
)
+ location = move_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
# the remaining move line still needs to be picked
response,
next_state="unload_single",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dest": {
- "id": move_lines[0].location_dest_id.id,
- "name": move_lines[0].location_dest_id.name,
- },
- },
+ data=data,
)
@@ -435,18 +389,10 @@ def test_unload_scan_pack_ok(self):
"barcode": self.bin1.name,
},
)
+ location = self.move_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
- response,
- next_state="unload_set_destination",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dest": {
- "id": self.move_lines[0].location_dest_id.id,
- "name": self.move_lines[0].location_dest_id.name,
- },
- },
+ response, next_state="unload_set_destination", data=data,
)
def test_unload_scan_pack_wrong_barcode(self):
@@ -459,18 +405,12 @@ def test_unload_scan_pack_wrong_barcode(self):
"barcode": self.bin2.name,
},
)
+ location = self.move_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
response,
next_state="unload_single",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dest": {
- "id": self.move_lines[0].location_dest_id.id,
- "name": self.move_lines[0].location_dest_id.name,
- },
- },
+ data=data,
message={"message_type": "error", "body": "Wrong bin"},
)
@@ -550,19 +490,10 @@ def test_unload_scan_destination_ok(self):
)
self.assertRecordValues(self.batch, [{"state": "in_progress"}])
+ location = self.bin2_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=self.bin2)
self.assert_response(
- response,
- next_state="unload_single",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- # the line of bin1 is unloaded, next one will be bin2
- "package": {"id": self.bin2.id, "name": self.bin2.name},
- "location_dest": {
- "id": self.bin2_lines[0].location_dest_id.id,
- "name": self.bin2_lines[0].location_dest_id.name,
- },
- },
+ response, next_state="unload_single", data=data,
)
def test_unload_scan_destination_one_line_of_picking_only(self):
@@ -619,20 +550,10 @@ def test_unload_scan_destination_one_line_of_picking_only(self):
],
)
self.assertRecordValues(self.batch, [{"state": "in_progress"}])
-
+ location = bin3_line.location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=bin3)
self.assert_response(
- response,
- next_state="unload_single",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- # the line of bin2 is unloaded, next one will be bin3
- "package": {"id": bin3.id, "name": bin3.name},
- "location_dest": {
- "id": bin3_line.location_dest_id.id,
- "name": bin3_line.location_dest_id.name,
- },
- },
+ response, next_state="unload_single", data=data,
)
def test_unload_scan_destination_last_line(self):
@@ -692,18 +613,12 @@ def test_unload_scan_destination_error_location_not_found(self):
"barcode": "¤",
},
)
+ location = self.bin1_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
response,
next_state="unload_set_destination",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dest": {
- "id": self.bin1_lines[0].location_dest_id.id,
- "name": self.bin1_lines[0].location_dest_id.name,
- },
- },
+ data=data,
message={
"message_type": "error",
"body": "No location found for this barcode.",
@@ -724,18 +639,12 @@ def test_unload_scan_destination_error_location_invalid(self):
"barcode": self.dispatch_location.barcode,
},
)
+ location = self.bin1_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
response,
next_state="unload_set_destination",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dest": {
- "id": self.bin1_lines[0].location_dest_id.id,
- "name": self.bin1_lines[0].location_dest_id.name,
- },
- },
+ data=data,
message={"message_type": "error", "body": "You cannot place it here"},
)
@@ -749,18 +658,10 @@ def test_unload_scan_destination_need_confirmation(self):
"barcode": self.packing_b_location.barcode,
},
)
+ location = self.bin1_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
- response,
- next_state="confirm_unload_set_destination",
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- "package": {"id": self.bin1.id, "name": self.bin1.name},
- "location_dest": {
- "id": self.bin1_lines[0].location_dest_id.id,
- "name": self.bin1_lines[0].location_dest_id.name,
- },
- },
+ response, next_state="confirm_unload_set_destination", data=data,
)
def test_unload_scan_destination_with_confirmation(self):
@@ -817,7 +718,8 @@ def test_unload_scan_destination_completion_info(self):
"barcode": dest_location.barcode,
},
)
-
+ location = self.bin2_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=self.bin2)
self.assert_response(
response,
next_state="unload_single",
@@ -825,14 +727,5 @@ def test_unload_scan_destination_completion_info(self):
"body": "Last operation of transfer {}. Next operation "
"({}) is ready to proceed.".format(picking.name, next_picking.name)
},
- data={
- "id": self.batch.id,
- "name": self.batch.name,
- # the line of bin1 is unloaded, next one will be bin2
- "package": {"id": self.bin2.id, "name": self.bin2.name},
- "location_dest": {
- "id": self.bin2_lines[0].location_dest_id.id,
- "name": self.bin2_lines[0].location_dest_id.name,
- },
- },
+ data=data,
)
diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py
index 81616439f4..87128304e0 100644
--- a/shopfloor/tests/test_scan_anything.py
+++ b/shopfloor/tests/test_scan_anything.py
@@ -29,35 +29,35 @@ def test_scan_product(self):
record.barcode = "PROD-B"
rec_type = "product"
identifier = record.barcode
- data = self.data.product_detail(record)
+ data = self.data_detail.product_detail(record)
self._test_response_ok(rec_type, data, identifier)
def test_scan_location(self):
record = self.stock_location
rec_type = "location"
identifier = record.barcode
- data = self.data.location_detail(record)
+ data = self.data_detail.location_detail(record)
self._test_response_ok(rec_type, data, identifier)
def test_scan_package(self):
record = self.package
rec_type = "package"
identifier = record.name
- data = self.data.package_detail(record)
+ data = self.data_detail.package_detail(record)
self._test_response_ok(rec_type, data, identifier)
def test_scan_lot(self):
record = self.lot
rec_type = "lot"
identifier = record.name
- data = self.data.lot_detail(record)
+ data = self.data_detail.lot_detail(record)
self._test_response_ok(rec_type, data, identifier)
def test_scan_transfer(self):
record = self.picking
rec_type = "transfer"
identifier = record.name
- data = self.data.picking_detail(record)
+ data = self.data_detail.picking_detail(record)
self._test_response_ok(rec_type, data, identifier)
def test_scan_error(self):
From bd01a6dd5dc37418adc4e3c61f17a220e9d2d9f8 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 19 May 2020 16:45:25 +0200
Subject: [PATCH 218/940] backend: cluster_picking add message for line done
---
shopfloor/actions/message.py | 6 ++++++
shopfloor/services/cluster_picking.py | 6 +++++-
shopfloor/tests/test_cluster_picking_unload.py | 1 +
3 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index f565b5e823..aa68853d4a 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -224,3 +224,9 @@ def batch_transfer_complete(self):
"message_type": "success",
"body": _("Batch Transfer complete"),
}
+
+ def batch_transfer_line_done(self):
+ return {
+ "message_type": "success",
+ "body": _("Batch Transfer line done"),
+ }
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index c0ba42fc97..632b802c86 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1153,7 +1153,11 @@ def _unload_end(self, batch, completion_info_popup=None):
next_line = self._next_line_for_pick(batch)
if next_line:
- return self._response_for_start_line(next_line, popup=completion_info_popup)
+ return self._response_for_start_line(
+ next_line,
+ message=self.msg_store.batch_transfer_line_done(),
+ popup=completion_info_popup,
+ )
else:
# TODO add tests for this (for instance a picking is not 'done'
# because a move was unassigned, we want to validate the batch to
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 9d43419d80..2152b79d0c 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -206,6 +206,7 @@ def test_set_destination_all_remaining_lines(self):
response,
next_state="start_line",
data=self._line_data(move_lines[2]),
+ message={"body": "Batch Transfer line done", "message_type": "success"},
)
def test_set_destination_all_but_different_dest(self):
From 9bb17a1ada9ac142893d992b5ad9894734630f50 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 20 May 2020 08:15:08 +0200
Subject: [PATCH 219/940] backend: fix cluster picking sorting lines
---
shopfloor/services/cluster_picking.py | 2 +-
shopfloor/tests/test_cluster_picking_skip.py | 52 +++++++++++---------
2 files changed, 29 insertions(+), 25 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 632b802c86..4565801184 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -323,8 +323,8 @@ def _pick_next_line(self, batch, message=None, force_line=None):
@staticmethod
def _sort_key_lines(line):
return (
- line.location_id,
line.shopfloor_postponed,
+ line.location_id.name,
line.move_id.sequence,
line.move_id.id,
line.id,
diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py
index 2e78a3acc2..8e1a004c10 100644
--- a/shopfloor/tests/test_cluster_picking_skip.py
+++ b/shopfloor/tests/test_cluster_picking_skip.py
@@ -34,34 +34,38 @@ def _skip_line(self, line, next_line=None):
return response
def test_skip_line(self):
+ # put one picking in another location
+ self.batch.picking_ids[1].location_id = self.shelf1
+ self.batch.picking_ids[1].move_lines.location_id = self.shelf1
+ # select batch
self._simulate_batch_selected(self.batch, in_package=True)
- lines = self.batch.picking_ids.move_line_ids.sorted(
- lambda line: (
- line.location_id,
- line.shopfloor_postponed,
- line.move_id.sequence,
- line.move_id.id,
- line.id,
- )
+
+ # enforce names to have reliable sorting
+ self.stock_location.name = "LOC2"
+ self.shelf1.name = "LOC1"
+ all_lines = self.batch.picking_ids.move_line_ids
+ loc1_lines = all_lines.filtered(lambda line: (line.location_id == self.shelf1))
+ loc2_lines = all_lines.filtered(
+ lambda line: (line.location_id == self.stock_location)
+ )
+ # no line postponed yet
+ self.assertEqual(
+ all_lines.mapped("shopfloor_postponed"), [False, False, False, False]
)
- self.assertFalse(lines[0].shopfloor_postponed)
- self._skip_line(lines[0], lines[1])
- self.assertTrue(lines[0].shopfloor_postponed)
- # 2nd line, next is 3rd
- self.assertFalse(lines[1].shopfloor_postponed)
- self._skip_line(lines[1], lines[2])
- self.assertTrue(lines[1].shopfloor_postponed)
+ # skip line from loc 1
+ self._skip_line(loc1_lines[0], loc1_lines[1])
+ self.assertTrue(loc1_lines[0].shopfloor_postponed)
+
+ # 2nd line, next is 1st from 2nd location
+ self.assertFalse(loc1_lines[1].shopfloor_postponed)
+ self._skip_line(loc1_lines[1], loc2_lines[0])
+ self.assertTrue(loc1_lines[1].shopfloor_postponed)
+
# 3rd line, next is 4th
- self.assertFalse(lines[2].shopfloor_postponed)
- self._skip_line(lines[2], lines[3])
- self.assertTrue(lines[2].shopfloor_postponed)
- # 4th line, next is 1st
- # the next line for the last one is the 1st,
- # because you'll have to process it anyway
- self.assertFalse(lines[3].shopfloor_postponed)
- self._skip_line(lines[3], lines[0])
- self.assertTrue(lines[3].shopfloor_postponed)
+ self.assertFalse(loc2_lines[0].shopfloor_postponed)
+ self._skip_line(loc2_lines[0], loc2_lines[1])
+ self.assertTrue(loc2_lines[0].shopfloor_postponed)
# TODO tests for transitions to next line / no next lines, ...
From 6057e5a620a67eccf18d7cc5e0a91cab29e8368d Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 20 May 2020 08:57:40 +0200
Subject: [PATCH 220/940] backend: move some key stuff to tests common
---
shopfloor/tests/common.py | 25 ++++++++++++++++++++
shopfloor/tests/test_actions_data_detail.py | 25 ++++++++------------
shopfloor/tests/test_cluster_picking_base.py | 21 ----------------
3 files changed, 35 insertions(+), 36 deletions(-)
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 1cacd73b78..db21e2a791 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -71,6 +71,10 @@ def setUpClass(cls):
cls.data = work.component(usage="data")
with cls.work_on_actions(cls) as work:
cls.data_detail = work.component(usage="data_detail")
+ with cls.work_on_services(cls) as work:
+ cls.schema = work.component(usage="schema")
+ with cls.work_on_services(cls) as work:
+ cls.schema_detail = work.component(usage="schema_detail")
@classmethod
def setUpClassVars(cls):
@@ -263,3 +267,24 @@ def _create_picking_batch(cls, products):
batch.picking_ids.action_confirm()
batch.picking_ids.action_assign()
return batch
+
+ @classmethod
+ def _simulate_batch_selected(
+ cls, batches, in_package=False, in_lot=False, fill_stock=True
+ ):
+ """Create a state as if a batch was selected by the user
+
+ * The picking batch is in progress
+ * It is assigned to the current user
+ * All the move lines are available
+
+ Note: currently, this method create a source package that contains
+ all the products of the batch. It is enough for the current tests.
+ """
+ pickings = batches.mapped("picking_ids")
+ if fill_stock:
+ cls._fill_stock_for_moves(
+ pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot
+ )
+ pickings.action_assign()
+ batches.write({"state": "in_progress", "user_id": cls.env.uid})
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index c208d522c9..2ff4ccf67e 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -27,11 +27,6 @@ def setUpClass(cls):
"stock_storage_type.package_storage_type_pallets"
)
- def setUp(self):
- super().setUp()
- with self.work_on_services() as work:
- self.schema = work.component(usage="schema_detail")
-
def _expected_location_detail(self, record, **kw):
return dict(
**self._expected_location(record),
@@ -83,7 +78,7 @@ class ActionsDataDetailCase(ActionsDataDetailCaseBase):
def test_data_location(self):
location = self.stock_location
data = self.data_detail.location_detail(location)
- self.assert_schema(self.schema.location_detail(), data)
+ self.assert_schema(self.schema_detail.location_detail(), data)
move_lines = self.env["stock.move.line"].search(
[
("location_id", "=", location.id),
@@ -97,7 +92,7 @@ def test_data_location(self):
def test_data_packaging(self):
data = self.data_detail.packaging(self.packaging)
- self.assert_schema(self.schema.packaging(), data)
+ self.assert_schema(self.schema_detail.packaging(), data)
expected = {"id": self.packaging.id, "name": self.packaging.name}
self.assertDictEqual(data, expected)
@@ -112,7 +107,7 @@ def test_data_lot(self):
}
)
data = self.data_detail.lot_detail(lot)
- self.assert_schema(self.schema.lot_detail(), data)
+ self.assert_schema(self.schema_detail.lot_detail(), data)
expected = {
"id": lot.id,
@@ -131,7 +126,7 @@ def test_data_package(self):
package.package_storage_type_id = self.storage_type_pallet
# package.invalidate_cache()
data = self.data_detail.package_detail(package, picking=self.picking)
- self.assert_schema(self.schema.package_detail(), data)
+ self.assert_schema(self.schema_detail.package_detail(), data)
lines = self.env["stock.move.line"].search(
[("package_id", "=", package.id), ("state", "not in", ("done", "cancel"))]
@@ -165,7 +160,7 @@ def test_data_picking(self):
)
picking.move_lines.write({"date_expected": "2020-05-13"})
data = self.data_detail.picking_detail(picking)
- self.assert_schema(self.schema.picking_detail(), data)
+ self.assert_schema(self.schema_detail.picking_detail(), data)
expected = {
"id": picking.id,
"move_line_count": 4,
@@ -193,7 +188,7 @@ def test_data_move_line_package(self):
)
move_line.write({"qty_done": 3.0, "result_package_id": result_package.id})
data = self.data_detail.move_line(move_line)
- self.assert_schema(self.schema.move_line(), data)
+ self.assert_schema(self.schema_detail.move_line(), data)
product = self.product_a.with_context(location=move_line.location_id.id)
expected = {
"id": move_line.id,
@@ -221,7 +216,7 @@ def test_data_move_line_package(self):
def test_data_move_line_lot(self):
move_line = self.move_b.move_line_ids
data = self.data_detail.move_line(move_line)
- self.assert_schema(self.schema.move_line(), data)
+ self.assert_schema(self.schema_detail.move_line(), data)
product = self.product_b.with_context(location=move_line.location_id.id)
expected = {
"id": move_line.id,
@@ -244,7 +239,7 @@ def test_data_move_line_package_lot(self):
self.maxDiff = None
move_line = self.move_c.move_line_ids
data = self.data_detail.move_line(move_line)
- self.assert_schema(self.schema.move_line(), data)
+ self.assert_schema(self.schema_detail.move_line(), data)
product = self.product_c.with_context(location=move_line.location_id.id)
expected = {
"id": move_line.id,
@@ -276,7 +271,7 @@ def test_data_move_line_package_lot(self):
def test_data_move_line_raw(self):
move_line = self.move_d.move_line_ids
data = self.data_detail.move_line(move_line)
- self.assert_schema(self.schema.move_line(), data)
+ self.assert_schema(self.schema_detail.move_line(), data)
product = self.product_d.with_context(location=move_line.location_id.id)
expected = {
"id": move_line.id,
@@ -320,6 +315,6 @@ def test_product(self):
}
)
data = self.data_detail.product_detail(product)
- self.assert_schema(self.schema.product_detail(), data)
+ self.assert_schema(self.schema_detail.product_detail(), data)
expected = self._expected_product_detail(product, full=True)
self.assertDictEqual(data, expected)
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index bd551f0c80..3453ede428 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -16,27 +16,6 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="cluster_picking")
- @classmethod
- def _simulate_batch_selected(
- cls, batches, in_package=False, in_lot=False, fill_stock=True
- ):
- """Create a state as if a batch was selected by the user
-
- * The picking batch is in progress
- * It is assigned to the current user
- * All the move lines are available
-
- Note: currently, this method create a source package that contains
- all the products of the batch. It is enough for the current tests.
- """
- pickings = batches.mapped("picking_ids")
- if fill_stock:
- cls._fill_stock_for_moves(
- pickings.mapped("move_lines"), in_package=in_package, in_lot=in_lot
- )
- pickings.action_assign()
- batches.write({"state": "in_progress", "user_id": cls.env.uid})
-
def _line_data(self, move_line, qty=None, package_dest=False):
picking = move_line.picking_id
# A package exists on the move line, because the quant created
From ff1fad115e3e3cb73773d44e9b59bee6e203c7a7 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 20 May 2020 09:00:07 +0200
Subject: [PATCH 221/940] backend: fix picking batch conversion + add test
---
shopfloor/models/stock_picking_batch.py | 4 +-
shopfloor/services/cluster_picking.py | 4 +-
shopfloor/services/schema.py | 7 ++-
shopfloor/tests/test_actions_data.py | 62 ++++++++++++++++++++++---
4 files changed, 64 insertions(+), 13 deletions(-)
diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py
index aff3ef2ecf..07bf57a099 100644
--- a/shopfloor/models/stock_picking_batch.py
+++ b/shopfloor/models/stock_picking_batch.py
@@ -17,7 +17,9 @@ class StockPickingBatch(models.Model):
help="Technical field. Indicates total weight of transfers included.",
)
- @api.depends("picking_ids.total_weight", "picking_ids.move_line_ids")
+ @api.depends(
+ "picking_ids.state", "picking_ids.total_weight", "picking_ids.move_line_ids"
+ )
def _compute_picking_info(self):
for item in self:
assigned_pickings = item.picking_ids.filtered(
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 4565801184..9653e9f743 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1550,9 +1550,7 @@ def unload_scan_destination(self):
@property
def _schema_for_batch_details(self):
- schema = self.schemas.picking_batch()
- schema["pickings"] = self.schemas._schema_list_of(self.schemas.picking())
- return schema
+ return self.schemas.picking_batch(with_pickings=True)
@property
def _schema_for_single_line_details(self):
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index d97dbf36b6..78e8545520 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -148,14 +148,17 @@ def packaging(self):
"name": {"type": "string", "nullable": False, "required": True},
}
- def picking_batch(self):
- return {
+ def picking_batch(self, with_pickings=True):
+ schema = {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"picking_count": {"required": True, "type": "integer"},
"move_line_count": {"required": True, "type": "integer"},
"weight": {"required": True, "nullable": True, "type": "float"},
}
+ if with_pickings:
+ schema["pickings"] = self._schema_list_of(self.picking())
+ return schema
def package_level(self):
return {
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 4f44ad4dcd..64f95e8399 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -1,6 +1,6 @@
import logging
-from .common import CommonCase
+from .common import CommonCase, PickingBatchMixin
_logger = logging.getLogger(__name__)
@@ -64,11 +64,6 @@ def _expected_product(self, record, **kw):
class ActionsDataCase(ActionsDataCaseBase):
- def setUp(self):
- super().setUp()
- with self.work_on_services() as work:
- self.schema = work.component(usage="schema")
-
def test_data_packaging(self):
data = self.data.packaging(self.packaging)
self.assert_schema(self.schema.packaging(), data)
@@ -183,7 +178,6 @@ def test_data_move_line_lot(self):
self.assertDictEqual(data, expected)
def test_data_move_line_package_lot(self):
- self.maxDiff = None
move_line = self.move_c.move_line_ids
data = self.data.move_line(move_line)
self.assert_schema(self.schema.move_line(), data)
@@ -232,3 +226,57 @@ def test_data_move_line_raw(self):
"location_dest": self._expected_location(move_line.location_dest_id),
}
self.assertDictEqual(data, expected)
+
+
+class ActionsDataCaseBatchPicking(ActionsDataCaseBase, PickingBatchMixin):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.batch = cls._create_picking_batch(
+ [
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10),
+ cls.BatchProduct(product=cls.product_b, quantity=20),
+ ],
+ [cls.BatchProduct(product=cls.product_a, quantity=30)],
+ ]
+ )
+
+ def test_data_picking_batch(self):
+ data = self.data.picking_batch(self.batch)
+ self.assert_schema(self.schema.picking_batch(), data)
+ # no assigned pickings
+ expected = {
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "picking_count": 0,
+ "move_line_count": 0,
+ "weight": 0.0,
+ }
+ self.assertDictEqual(data, expected)
+
+ self._simulate_batch_selected(self.batch, fill_stock=True)
+ expected.update(
+ {
+ "picking_count": 2,
+ "move_line_count": 3,
+ "weight": sum(self.batch.picking_ids.mapped("total_weight")),
+ }
+ )
+ data = self.data.picking_batch(self.batch)
+ self.assertDictEqual(data, expected)
+
+ def test_data_picking_batch_with_pickings(self):
+ self._simulate_batch_selected(self.batch, fill_stock=True)
+ data = self.data.picking_batch(self.batch, with_pickings=True)
+ self.assert_schema(self.schema.picking_batch(with_pickings=True), data)
+ # no assigned pickings
+ expected = {
+ "id": self.batch.id,
+ "name": self.batch.name,
+ "picking_count": 2,
+ "move_line_count": 3,
+ "weight": sum(self.batch.picking_ids.mapped("total_weight")),
+ "pickings": self.data.pickings(self.batch.picking_ids),
+ }
+ self.assertDictEqual(data, expected)
From 17b23a2494cd7de03439b2bc840330ea8aa23550 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 20 May 2020 17:29:24 +0200
Subject: [PATCH 222/940] backend: allow edit control flags on move lines
---
shopfloor/views/stock_move_line.xml | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/shopfloor/views/stock_move_line.xml b/shopfloor/views/stock_move_line.xml
index 3945954cdb..5b970af673 100644
--- a/shopfloor/views/stock_move_line.xml
+++ b/shopfloor/views/stock_move_line.xml
@@ -10,22 +10,20 @@
+
From 4458fbafd4bc77104feea146a11cc2257824b111 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 22 May 2020 14:14:18 +0200
Subject: [PATCH 223/940] backend: fix picking summary data (missing packaging)
---
shopfloor/services/checkout.py | 39 ++++++++++++++++++++++------------
shopfloor/services/schema.py | 6 +++---
2 files changed, 29 insertions(+), 16 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 98db69b2db..b00291df70 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -152,15 +152,21 @@ def _response_for_summary(self, picking, need_confirm=False, message=None):
def _response_for_select_document(self, message=None):
return self._response(next_state="select_document", message=message)
- def _data_for_move_lines(self, lines):
+ def _data_for_move_lines(self, lines, **kw):
data_struct = self.actions_for("data")
- return data_struct.move_lines(lines)
+ return data_struct.move_lines(lines, **kw)
def _data_for_stock_picking(self, picking, done=False):
data_struct = self.actions_for("data")
data = data_struct.picking(picking)
line_picker = self._lines_checkout_done if done else self._lines_to_pack
- data.update({"move_lines": self._data_for_move_lines(line_picker(picking))})
+ data.update(
+ {
+ "move_lines": self._data_for_move_lines(
+ line_picker(picking), with_packaging=done
+ )
+ }
+ )
return data
def _lines_checkout_done(self, picking):
@@ -576,7 +582,7 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package):
return self._response_for_select_line(
picking,
message={
- "message_type": "info",
+ "message_type": "success",
"body": _("Product(s) packed in {}").format(package.name),
},
)
@@ -1167,28 +1173,35 @@ def _states(self):
"select_dest_package": self._schema_select_package,
"summary": self._schema_summary,
"change_packaging": self._schema_select_packaging,
- "confirm_done": self._schema_stock_picking_details,
+ "confirm_done": self._schema_confirm_done,
}
- @property
- def _schema_stock_picking_details(self):
+ def _schema_stock_picking(self, lines_with_packaging=False):
schema = self.schemas.picking()
schema.update(
{
- "move_lines": {
- "type": "list",
- "schema": {"type": "dict", "schema": self.schemas.move_line()},
- },
+ "move_lines": self.schemas._schema_list_of(
+ self.schemas.move_line(with_packaging=lines_with_packaging)
+ ),
}
)
- return {"picking": {"type": "dict", "schema": schema}}
+ return {"picking": self.schemas._schema_dict_of(schema, required=True)}
+
+ @property
+ def _schema_stock_picking_details(self):
+ return self._schema_stock_picking()
@property
def _schema_summary(self):
return dict(
- self._schema_stock_picking_details, all_processed={"type": "boolean"}
+ self._schema_stock_picking(lines_with_packaging=True),
+ all_processed={"type": "boolean"},
)
+ @property
+ def _schema_confirm_done(self):
+ return self._schema_stock_picking(lines_with_packaging=True)
+
@property
def _schema_selection_list(self):
return {
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 78e8545520..999c78c658 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -67,7 +67,7 @@ def picking(self):
},
}
- def move_line(self):
+ def move_line(self, with_packaging=False):
return {
"id": {"type": "integer", "required": True},
"qty_done": {"type": "float", "required": True},
@@ -83,13 +83,13 @@ def move_line(self):
"type": "dict",
"required": True,
"nullable": True,
- "schema": self.package(),
+ "schema": self.package(with_packaging=with_packaging),
},
"package_dest": {
"type": "dict",
"required": False,
"nullable": True,
- "schema": self.package(),
+ "schema": self.package(with_packaging=with_packaging),
},
"location_src": {
"type": "dict",
From 56ecc902e01d904e7960b122ad36b3201dc2b827 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 22 May 2020 14:14:46 +0200
Subject: [PATCH 224/940] backend: cluster picking fix list batch w/out
pickings
---
shopfloor/services/cluster_picking.py | 42 +++++++++++++++------------
1 file changed, 24 insertions(+), 18 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 9653e9f743..c72435ca72 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -222,26 +222,32 @@ def _batch_picking_search(self, name_fragment=None, batch_ids=None):
if batch_ids:
domain = expression.AND([domain, [("id", "in", batch_ids)]])
records = self.env["stock.picking.batch"].search(domain, order="id asc")
- records = records.filtered(
- # Include done/cancel because we want to be able to work on the
- # batch even if some pickings are done/canceled. They'll should be
- # ignored later.
- lambda batch: all(
- (
- # When the batch is already in progress, we do not care
- # about state of the pickings, because we want to be able
- # to recover it in any case, even if, for instance, a stock
- # error changed a picking to unavailable after the user
- # started to work on the batch.
- batch.state == "in_progress"
- or picking.state in ("assigned", "done", "cancel")
- )
- and picking.picking_type_id in self.picking_types
- for picking in batch.picking_ids
- )
- )
+ records = records.filtered(self._batch_filter)
return records
+ def _batch_filter(self, batch):
+ if not batch.picking_ids:
+ return False
+ return batch.picking_ids.filtered(self._batch_picking_filter)
+
+ def _batch_picking_filter(self, picking):
+ # Picking type guard
+ if picking.picking_type_id not in self.picking_types:
+ return False
+ # Include done/cancel because we want to be able to work on the
+ # batch even if some pickings are done/canceled. They'll should be
+ # ignored later.
+ # When the batch is already in progress, we do not care
+ # about state of the pickings, because we want to be able
+ # to recover it in any case, even if, for instance, a stock
+ # error changed a picking to unavailable after the user
+ # started to work on the batch.
+ return picking.batch_id.state == "in_progress" or picking.state in (
+ "assigned",
+ "done",
+ "cancel",
+ )
+
# TODO this may be used in other scenarios? if so, extract
def _select_a_picking_batch(self, batches):
# look for in progress + assigned to self first
From acba2cc89dec7cb9f3cfc6afcef006fb1fa39958 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 22 May 2020 14:59:21 +0200
Subject: [PATCH 225/940] backend: checkout fixes
---
shopfloor/services/checkout.py | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index b00291df70..73cb1395d2 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -706,11 +706,13 @@ def no_package(self, picking_id, selected_line_ids):
if not picking.exists():
return self._response_stock_picking_does_not_exist()
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
- selected_lines.write({"shopfloor_checkout_done": True})
+ selected_lines.write(
+ {"shopfloor_checkout_done": True, "result_package_id": False}
+ )
return self._response_for_select_line(
picking,
message={
- "message_type": "info",
+ "message_type": "success",
"body": _("Product(s) processed as raw product(s)"),
},
)
@@ -938,10 +940,13 @@ def cancel_line(self, picking_id, package_id=None, line_id=None):
"shopfloor_checkout_done": False,
}
)
+ msg = _("Package cancelled")
if line:
line.write({"qty_done": 0, "shopfloor_checkout_done": False})
-
- return self._response_for_summary(picking)
+ msg = _("Line cancelled")
+ return self._response_for_summary(
+ picking, message={"message_type": "success", "body": msg}
+ )
def done(self, picking_id, confirmation=False):
"""Set the moves as done
From 73701a31f034ad9808650abfcd95bb9a8d46f80c Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 22 May 2020 15:46:21 +0200
Subject: [PATCH 226/940] backend: fix success messages
---
shopfloor/actions/message.py | 2 +-
shopfloor/services/cluster_picking.py | 4 ++--
shopfloor/tests/test_checkout_cancel_line.py | 2 ++
shopfloor/tests/test_checkout_list_package.py | 2 +-
shopfloor/tests/test_checkout_new_package.py | 2 +-
shopfloor/tests/test_checkout_no_package.py | 2 +-
.../tests/test_checkout_scan_package_action.py | 6 +++---
.../tests/test_cluster_picking_change_pack_lot.py | 14 +++++++-------
shopfloor/tests/test_single_pack_putaway.py | 4 ++--
shopfloor/tests/test_single_pack_transfer.py | 4 ++--
10 files changed, 22 insertions(+), 20 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index aa68853d4a..30bc8e10fd 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -123,7 +123,7 @@ def already_done(self):
def confirm_canceled_scan_next_pack(self):
return {
- "message_type": "info",
+ "message_type": "success",
"body": _("Canceled, you can scan a new pack."),
}
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index c72435ca72..8ad83d8ac1 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -986,7 +986,7 @@ def _change_pack_lot_change_lot(self, move_line, lot):
success_body += _(" A draft inventory has been created for control.")
return self._response_for_scan_destination(
- move_line, message={"message_type": "info", "body": success_body}
+ move_line, message={"message_type": "success", "body": success_body}
)
def _package_identical_move_lines_qty(self, package, move_lines):
@@ -1075,7 +1075,7 @@ def _change_pack_lot_change_package(self, move_line, package):
return self._response_for_scan_destination(
move_line,
message={
- "message_type": "info",
+ "message_type": "success",
"body": _("Package {} replaced by package {}").format(
previous_package.name, package.name
),
diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py
index 2dce5bca77..392a525ccc 100644
--- a/shopfloor/tests/test_checkout_cancel_line.py
+++ b/shopfloor/tests/test_checkout_cancel_line.py
@@ -89,6 +89,7 @@ def test_cancel_package_ok(self):
"picking": self._stock_picking_data(picking, done=True),
"all_processed": False,
},
+ message={"body": "Package cancelled", "message_type": "success"},
)
def test_cancel_line_ok(self):
@@ -114,6 +115,7 @@ def test_cancel_line_ok(self):
"picking": self._stock_picking_data(picking, done=True),
"all_processed": False,
},
+ message={"body": "Line cancelled", "message_type": "success"},
)
def test_cancel_line_error_package_not_found(self):
diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py
index 2055f2041a..5a075d183a 100644
--- a/shopfloor/tests/test_checkout_list_package.py
+++ b/shopfloor/tests/test_checkout_list_package.py
@@ -144,7 +144,7 @@ def _assert_package_set(self, response):
next_state="select_line",
data={"picking": self._stock_picking_data(self.picking)},
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Product(s) packed in {}".format(self.pack1.name),
},
)
diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py
index 46403ac9cb..7e62c0f3f0 100644
--- a/shopfloor/tests/test_checkout_new_package.py
+++ b/shopfloor/tests/test_checkout_new_package.py
@@ -56,7 +56,7 @@ def test_new_package_ok(self):
next_state="select_line",
data={"picking": self._stock_picking_data(picking)},
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Product(s) packed in {}".format(new_package.name),
},
)
diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py
index 052c8626c4..f997ccad18 100644
--- a/shopfloor/tests/test_checkout_no_package.py
+++ b/shopfloor/tests/test_checkout_no_package.py
@@ -48,7 +48,7 @@ def test_no_package_ok(self):
next_state="select_line",
data={"picking": self._stock_picking_data(picking)},
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Product(s) processed as raw product(s)",
},
)
diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py
index dcc78ce607..58edd46ded 100644
--- a/shopfloor/tests/test_checkout_scan_package_action.py
+++ b/shopfloor/tests/test_checkout_scan_package_action.py
@@ -165,7 +165,7 @@ def test_scan_package_action_scan_package_keep_source_package_ok(self):
next_state="select_line",
data={"picking": self._stock_picking_data(picking)},
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Product(s) packed in {}".format(pack1.name),
},
)
@@ -264,7 +264,7 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self):
"all_processed": True,
},
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Product(s) packed in {}".format(package.name),
},
)
@@ -349,7 +349,7 @@ def test_scan_package_action_scan_packaging_ok(self):
next_state="select_line",
data={"picking": self._stock_picking_data(picking)},
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Product(s) packed in {}".format(new_package.name),
},
)
diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
index 5e5c77a561..ed0f38d3d3 100644
--- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py
+++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
@@ -121,7 +121,7 @@ def test_change_pack_lot_change_pack_ok(self):
new_package.name,
success=True,
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Package {} replaced by package {}".format(
initial_package.name, new_package.name
),
@@ -161,7 +161,7 @@ def test_change_pack_lot_change_pack_different_location(self):
new_package.name,
success=True,
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Package {} replaced by package {}".format(
initial_package.name, new_package.name
),
@@ -197,7 +197,7 @@ def test_change_pack_lot_change_lot_in_package_ok(self):
new_lot.name,
success=True,
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Package {} replaced by package {}".format(
initial_package.name, new_package.name
),
@@ -286,7 +286,7 @@ def test_change_pack_lot_change_lot_ok(self):
new_lot.name,
success=True,
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Lot {} replaced by lot {}.".format(
initial_lot.name, new_lot.name
),
@@ -312,7 +312,7 @@ def test_change_pack_lot_change_lot_different_location_ok(self):
new_lot.name,
success=True,
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Lot {} replaced by lot {}. A draft inventory has"
" been created for control.".format(initial_lot.name, new_lot.name),
},
@@ -432,7 +432,7 @@ def test_change_pack_lot_change_pack_multi_content_with_lot(self):
new_package.name,
success=True,
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Package {} replaced by package {}".format(
initial_package.name, new_package.name
),
@@ -487,7 +487,7 @@ def test_change_pack_lot_change_pack_steal_from_other_move_line(self):
package2.name,
success=True,
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Package {} replaced by package {}".format(
package1.name, package2.name
),
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index 31d260afa5..c9c6fb21b7 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -512,7 +512,7 @@ def test_cancel(self):
response,
next_state="start",
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Canceled, you can scan a new pack.",
},
)
@@ -555,7 +555,7 @@ def test_cancel_already_canceled(self):
response,
next_state="start",
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Canceled, you can scan a new pack.",
},
)
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 510eaced85..bde20f97bc 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -657,7 +657,7 @@ def test_cancel(self):
response,
next_state="start",
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Canceled, you can scan a new pack.",
},
)
@@ -699,7 +699,7 @@ def test_cancel_already_canceled(self):
response,
next_state="start",
message={
- "message_type": "info",
+ "message_type": "success",
"body": "Canceled, you can scan a new pack.",
},
)
From f2c98e4f8d64d69382a246e02d401385ae33443d Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 26 May 2020 12:24:43 +0200
Subject: [PATCH 227/940] backend: run tests as a stock user
We have to be sure that all scenarii can be run with a normal
stock user, without needing manager permissions.
Now the test are all run as normal stock user, to verify this.
The consequences on writing tests:
* Records created or written in a test setup must use sudo()
if the user has no permission on these models.
* Tests setUps should no longer extend setUpClass but setUpClassVars
and setUpClassBaseData, which already have an environment with
the stock user.
* Be wary of creating records before setUpClassUsers is called, because
it their "env.user" would be admin and could lead to inconsistencies
in tests.
The services / actions which needed a correction for stock users:
* Creating an inventory (cluster picking stock issue, ...)
* Validating an inventory (cluster picking stock issue, ...)
* In cluster's picking when a package is changed,
'StockQuant.inventory_quantity' is updated, this field automatically
create moves to adjust quantities. However, it was ignored if the user
doesn't have the 'stock manager' group.
* OpenAPI: the swagger UI could not work without being admin because it
reads the demo auth API key, which is obviously restricted. Reading it
as sudo is safe as it's a demo key.
---
shopfloor/actions/inventory.py | 12 +-
shopfloor/models/__init__.py | 2 +
shopfloor/models/stock_inventory.py | 17 ++
shopfloor/models/stock_quant.py | 17 ++
shopfloor/services/service.py | 6 +-
shopfloor/tests/common.py | 163 +++++++++++++-----
shopfloor/tests/test_actions_data.py | 14 +-
shopfloor/tests/test_actions_data_detail.py | 24 ++-
shopfloor/tests/test_app.py | 6 +-
shopfloor/tests/test_checkout_base.py | 10 +-
shopfloor/tests/test_checkout_cancel_line.py | 4 +-
.../tests/test_checkout_change_packaging.py | 70 ++++----
shopfloor/tests/test_checkout_done.py | 8 +-
shopfloor/tests/test_checkout_list_package.py | 4 +-
.../test_checkout_scan_package_action.py | 20 ++-
shopfloor/tests/test_checkout_select.py | 6 +-
shopfloor/tests/test_checkout_select_line.py | 4 +-
shopfloor/tests/test_checkout_set_qty.py | 4 +-
shopfloor/tests/test_cluster_picking_base.py | 16 +-
shopfloor/tests/test_cluster_picking_batch.py | 25 ++-
.../test_cluster_picking_change_pack_lot.py | 12 +-
shopfloor/tests/test_cluster_picking_scan.py | 8 +-
.../tests/test_cluster_picking_select.py | 8 +-
shopfloor/tests/test_cluster_picking_skip.py | 10 +-
.../tests/test_cluster_picking_stock_issue.py | 6 +-
.../tests/test_cluster_picking_unload.py | 54 +++---
shopfloor/tests/test_delivery_base.py | 11 +-
shopfloor/tests/test_menu.py | 12 +-
shopfloor/tests/test_openapi.py | 7 +-
shopfloor/tests/test_single_pack_putaway.py | 40 +++--
shopfloor/tests/test_single_pack_transfer.py | 40 +++--
31 files changed, 408 insertions(+), 232 deletions(-)
create mode 100644 shopfloor/models/stock_inventory.py
create mode 100644 shopfloor/models/stock_quant.py
diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py
index 9f71fd2243..f195c05d11 100644
--- a/shopfloor/actions/inventory.py
+++ b/shopfloor/actions/inventory.py
@@ -14,6 +14,12 @@ class InventoryAction(Component):
_inherit = "shopfloor.process.action"
_usage = "inventory"
+ @property
+ def inventory_model(self):
+ # the _sf_inventory key bypass groups checks,
+ # see comment in models/stock_inventory.py
+ return self.env["stock.inventory"].with_context(_sf_inventory=True)
+
def create_draft_check_empty(self, location, product, ref=None):
"""Create a draft inventory for a product with a zero quantity"""
if ref:
@@ -35,10 +41,10 @@ def _inventory_exists(
domain.append(("package_id", "=", package.id))
if lot is not None:
domain.append(("lot_id", "=", lot.id))
- return self.env["stock.inventory"].search_count(domain)
+ return self.inventory_model.search_count(domain)
def _create_draft_inventory(self, location, product, name):
- return self.env["stock.inventory"].create(
+ return self.inventory_model.sudo().create(
{
"name": name,
"location_ids": [(6, 0, location.ids)],
@@ -106,7 +112,7 @@ def create_stock_issue(self, move, location, package, lot):
values = self._stock_issue_inventory_values(
move, location, package, lot, qty_to_keep
)
- inventory = self.env["stock.inventory"].create(values)
+ inventory = self.inventory_model.sudo().create(values)
inventory.action_start()
inventory.action_validate()
move._action_assign()
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index b44bcc12eb..7f9b986965 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -1,9 +1,11 @@
from . import shopfloor_menu
from . import stock_picking_type
from . import shopfloor_profile
+from . import stock_inventory
from . import stock_location
from . import stock_move_line
from . import stock_package_level
from . import stock_picking
from . import stock_picking_batch
+from . import stock_quant
from . import stock_quant_package
diff --git a/shopfloor/models/stock_inventory.py b/shopfloor/models/stock_inventory.py
new file mode 100644
index 0000000000..3581a18dce
--- /dev/null
+++ b/shopfloor/models/stock_inventory.py
@@ -0,0 +1,17 @@
+from odoo import models
+
+
+class StockInventory(models.Model):
+ _inherit = "stock.inventory"
+
+ def user_has_groups(self, groups):
+ if self.env.context.get("_sf_inventory"):
+ allow_groups = groups.split(",")
+ # action_validate checks if the user is a manager, but
+ # in shopfloor, we want to programmatically create and
+ # validate inventories under the hood. sudo sets the su
+ # flag but not the group: allow to bypass the check when
+ # sudo is used.
+ if "stock.group_stock_manager" in allow_groups and self.env.su:
+ return True
+ return super().user_has_groups(groups)
diff --git a/shopfloor/models/stock_quant.py b/shopfloor/models/stock_quant.py
new file mode 100644
index 0000000000..77c6667359
--- /dev/null
+++ b/shopfloor/models/stock_quant.py
@@ -0,0 +1,17 @@
+from odoo import models
+
+
+class StockQuant(models.Model):
+ _inherit = "stock.quant"
+
+ def _is_inventory_mode(self):
+ """ Used to control whether a quant was written on or created during an
+ "inventory session", meaning a mode where we need to create the stock.move
+ record necessary to be consistent with the `inventory_quantity` field.
+ """
+ # The default method check if we have the stock.group_stock_manager
+ # group, however, we want to force using this mode from shopfloor
+ # (cluster picking) when sudo is used and the user is a stock user.
+ if self.env.context.get("inventory_mode") is True and self.env.su:
+ return True
+ return super()._is_inventory_mode()
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 66a7702f43..3841079f56 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -116,7 +116,11 @@ def _response(
def _get_openapi_default_parameters(self):
defaults = super()._get_openapi_default_parameters()
- demo_api_key = self.env.ref("shopfloor.api_key_demo", raise_if_not_found=False)
+ # Normal users can't read an API key, ignore it using sudo() only
+ # because it's a demo key.
+ demo_api_key = self.env.ref(
+ "shopfloor.api_key_demo", raise_if_not_found=False
+ ).sudo()
service_params = [
{
"name": "API-KEY",
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index db21e2a791..b83f076c0b 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -64,9 +64,12 @@ def setUpClass(cls):
cls.env = cls.env(
context=dict(cls.env.context, tracking_disable=cls.tracking_disable)
)
+
cls.setUpComponent()
+ cls.setUpClassUsers()
cls.setUpClassVars()
cls.setUpClassBaseData()
+
with cls.work_on_actions(cls) as work:
cls.data = work.component(usage="data")
with cls.work_on_actions(cls) as work:
@@ -76,73 +79,139 @@ def setUpClass(cls):
with cls.work_on_services(cls) as work:
cls.schema_detail = work.component(usage="schema_detail")
+ @classmethod
+ def setUpClassUsers(cls):
+ Users = cls.env["res.users"].with_context(
+ {"no_reset_password": True, "mail_create_nosubscribe": True}
+ )
+ cls.stock_user = Users.create(
+ {
+ "name": "Pauline Poivraisselle",
+ "login": "pauline2",
+ "email": "p.p@example.com",
+ "notification_type": "inbox",
+ "groups_id": [(6, 0, [cls.env.ref("stock.group_stock_user").id])],
+ }
+ )
+ cls.env = cls.env(user=cls.stock_user)
+
@classmethod
def setUpClassVars(cls):
stock_location = cls.env.ref("stock.stock_location_stock")
cls.stock_location = stock_location
cls.customer_location = cls.env.ref("stock.stock_location_customers")
- cls.customer_location.barcode = "CUSTOMERS"
cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone")
- cls.dispatch_location.barcode = "DISPATCH"
cls.packing_location = cls.env.ref("stock.location_pack_zone")
- cls.packing_location.barcode = "PACKING"
cls.input_location = cls.env.ref("stock.stock_location_company")
- cls.input_location.barcode = "INPUT"
cls.shelf1 = cls.env.ref("stock.stock_location_components")
- cls.shelf1.barcode = "SHELF1"
cls.shelf2 = cls.env.ref("stock.stock_location_14")
- cls.shelf2.barcode = "SHELF2"
- cls.customer = cls.env["res.partner"].create({"name": "Customer"})
@classmethod
def setUpClassBaseData(cls):
- cls.product_a = cls.env["product.product"].create(
- {
- "name": "Product A",
- "type": "product",
- "default_code": "A",
- "barcode": "A",
- "weight": 2,
- }
+ cls.customer = cls.env["res.partner"].sudo().create({"name": "Customer"})
+
+ cls.customer_location.sudo().barcode = "CUSTOMERS"
+ cls.dispatch_location.sudo().barcode = "DISPATCH"
+ cls.packing_location.sudo().barcode = "PACKING"
+ cls.input_location.sudo().barcode = "INPUT"
+ cls.shelf1.sudo().barcode = "SHELF1"
+ cls.shelf2.sudo().barcode = "SHELF2"
+
+ cls.product_a = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product A",
+ "type": "product",
+ "default_code": "A",
+ "barcode": "A",
+ "weight": 2,
+ }
+ )
)
- cls.product_a_packaging = cls.env["product.packaging"].create(
- {"name": "Box", "product_id": cls.product_a.id, "barcode": "ProductABox"}
+ cls.product_a_packaging = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "name": "Box",
+ "product_id": cls.product_a.id,
+ "barcode": "ProductABox",
+ }
+ )
)
- cls.product_b = cls.env["product.product"].create(
- {
- "name": "Product B",
- "type": "product",
- "default_code": "B",
- "barcode": "B",
- "weight": 3,
- }
+ cls.product_b = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product B",
+ "type": "product",
+ "default_code": "B",
+ "barcode": "B",
+ "weight": 3,
+ }
+ )
)
- cls.product_b_packaging = cls.env["product.packaging"].create(
- {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductBBox"}
+ cls.product_b_packaging = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "name": "Box",
+ "product_id": cls.product_b.id,
+ "barcode": "ProductBBox",
+ }
+ )
)
- cls.product_c = cls.env["product.product"].create(
- {
- "name": "Product C",
- "type": "product",
- "default_code": "C",
- "barcode": "C",
- "weight": 3,
- }
+ cls.product_c = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product C",
+ "type": "product",
+ "default_code": "C",
+ "barcode": "C",
+ "weight": 3,
+ }
+ )
)
- cls.product_c_packaging = cls.env["product.packaging"].create(
- {"name": "Box", "product_id": cls.product_b.id, "barcode": "ProductCBox"}
+ cls.product_c_packaging = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "name": "Box",
+ "product_id": cls.product_b.id,
+ "barcode": "ProductCBox",
+ }
+ )
)
- cls.product_d = cls.env["product.product"].create(
- {
- "name": "Product D",
- "type": "product",
- "default_code": "D",
- "barcode": "D",
- "weight": 3,
- }
+ cls.product_d = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product D",
+ "type": "product",
+ "default_code": "D",
+ "barcode": "D",
+ "weight": 3,
+ }
+ )
)
- cls.product_d_packaging = cls.env["product.packaging"].create(
- {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductDBox"}
+ cls.product_d_packaging = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "name": "Box",
+ "product_id": cls.product_d.id,
+ "barcode": "ProductDBox",
+ }
+ )
)
def assert_response(
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 64f95e8399..d0612fe1ee 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -13,11 +13,15 @@
class ActionsDataCaseBase(CommonCase):
@classmethod
- def setUpClass(cls):
- super().setUpClass()
+ def setUpClassVars(cls):
+ super().setUpClassVars()
cls.wh = cls.env.ref("stock.warehouse0")
cls.picking_type = cls.wh.out_type_id
- cls.packaging = cls.env["product.packaging"].create({"name": "Pallet"})
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.packaging = cls.env["product.packaging"].sudo().create({"name": "Pallet"})
cls.product_b.tracking = "lot"
cls.product_c.tracking = "lot"
cls.picking = cls._create_picking(
@@ -230,8 +234,8 @@ def test_data_move_line_raw(self):
class ActionsDataCaseBatchPicking(ActionsDataCaseBase, PickingBatchMixin):
@classmethod
- def setUpClass(cls):
- super().setUpClass()
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
cls.batch = cls._create_picking_batch(
[
[
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index 2ff4ccf67e..6f3c85d62d 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -17,12 +17,16 @@ def fake_colored_image(color="#4169E1", size=(800, 500)):
class ActionsDataDetailCaseBase(ActionsDataCaseBase):
@classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.package = cls.move_a.move_line_ids.package_id
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
cls.lot = cls.env["stock.production.lot"].create(
{"product_id": cls.product_b.id, "company_id": cls.env.company.id}
)
+ cls.package = cls.move_a.move_line_ids.package_id
+
+ @classmethod
+ def setUpClassVars(cls):
+ super().setUpClassVars()
cls.storage_type_pallet = cls.env.ref(
"stock_storage_type.package_storage_type_pallets"
)
@@ -289,16 +293,18 @@ def test_data_move_line_raw(self):
def test_product(self):
move_line = self.move_b.move_line_ids
product = move_line.product_id.with_context(location=move_line.location_id.id)
- manuf = self.env["res.partner"].create({"name": "Manuf 1"})
- product.write(
+ Partner = self.env["res.partner"].sudo()
+ manuf = Partner.create({"name": "Manuf 1"})
+ product.sudo().write(
{
"image_128": fake_colored_image(size=(128, 128)),
"manufacturer": manuf.id,
}
)
- vendor_a = self.env["res.partner"].create({"name": "Supplier A"})
- vendor_b = self.env["res.partner"].create({"name": "Supplier B"})
- self.env["product.supplierinfo"].create(
+ vendor_a = Partner.create({"name": "Supplier A"})
+ vendor_b = Partner.create({"name": "Supplier B"})
+ SupplierInfo = self.env["product.supplierinfo"].sudo()
+ SupplierInfo.create(
{
"name": vendor_a.id,
"product_tmpl_id": product.product_tmpl_id.id,
@@ -306,7 +312,7 @@ def test_product(self):
"product_code": "SUPP1",
}
)
- self.env["product.supplierinfo"].create(
+ SupplierInfo.create(
{
"name": vendor_b.id,
"product_tmpl_id": product.product_tmpl_id.id,
diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py
index dcfada2fb7..d45965861e 100644
--- a/shopfloor/tests/test_app.py
+++ b/shopfloor/tests/test_app.py
@@ -3,8 +3,8 @@
class AppCase(CommonCase):
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_hb_truck_demo")
cls.profile2 = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
@@ -61,7 +61,7 @@ def test_menu_no_profile(self):
def test_menu_by_profile(self):
"""Request /app/menu w/ a specific profile"""
# Simulate the client asking the menu
- menus = self.env["shopfloor.menu"].search([])
+ menus = self.env["shopfloor.menu"].sudo().search([])
menu = menus[0]
menu.profile_ids = self.profile
(menus - menu).profile_ids = self.profile2
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index 6dc4d8d885..25cbdc1dc5 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -3,14 +3,18 @@
class CheckoutCommonCase(CommonCase):
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout")
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
- cls.wh.delivery_steps = "pick_pack_ship"
cls.picking_type = cls.menu.picking_type_ids
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.wh.sudo().delivery_steps = "pick_pack_ship"
+
def setUp(self):
super().setUp()
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py
index 392a525ccc..108df0e03c 100644
--- a/shopfloor/tests/test_checkout_cancel_line.py
+++ b/shopfloor/tests/test_checkout_cancel_line.py
@@ -3,8 +3,8 @@
class CheckoutRemovePackageCase(CheckoutCommonCase):
@classmethod
- def setUpClass(cls):
- super().setUpClass()
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
cls.picking = picking = cls._create_picking(
lines=[
(cls.product_a, 10),
diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py
index e88d312a18..b7d4165837 100644
--- a/shopfloor/tests/test_checkout_change_packaging.py
+++ b/shopfloor/tests/test_checkout_change_packaging.py
@@ -3,37 +3,49 @@
class CheckoutListSetPackagingCase(CheckoutCommonCase):
@classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.packaging_pallet = cls.env["product.packaging"].create(
- {
- "sequence": 3,
- "name": "Pallet",
- "barcode": "PPP",
- "height": 100,
- "width": 100,
- "lngth": 100,
- }
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.packaging_pallet = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "sequence": 3,
+ "name": "Pallet",
+ "barcode": "PPP",
+ "height": 100,
+ "width": 100,
+ "lngth": 100,
+ }
+ )
)
- cls.packaging_box = cls.env["product.packaging"].create(
- {
- "sequence": 2,
- "name": "Box",
- "barcode": "BBB",
- "height": 20,
- "width": 20,
- "lngth": 20,
- }
+ cls.packaging_box = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "sequence": 2,
+ "name": "Box",
+ "barcode": "BBB",
+ "height": 20,
+ "width": 20,
+ "lngth": 20,
+ }
+ )
)
- cls.packaging_inner_box = cls.env["product.packaging"].create(
- {
- "sequence": 1,
- "name": "Inner Box",
- "barcode": "III",
- "height": 10,
- "width": 10,
- "lngth": 10,
- }
+ cls.packaging_inner_box = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "sequence": 1,
+ "name": "Inner Box",
+ "barcode": "III",
+ "height": 10,
+ "width": 10,
+ "lngth": 10,
+ }
+ )
)
cls.picking = cls._create_picking(lines=[(cls.product_a, 10)])
cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True)
diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py
index 8af9442973..bba6f7af58 100644
--- a/shopfloor/tests/test_checkout_done.py
+++ b/shopfloor/tests/test_checkout_done.py
@@ -24,8 +24,8 @@ def test_done_ok(self):
class CheckoutDonePartialCase(CheckoutCommonCase):
@classmethod
- def setUpClass(cls):
- super().setUpClass()
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
cls.picking = picking = cls._create_picking(
lines=[(cls.product_a, 10), (cls.product_b, 10)]
)
@@ -74,8 +74,8 @@ def test_done_partial_confirm(self):
class CheckoutDoneRawUnpackedCase(CheckoutCommonCase):
@classmethod
- def setUpClass(cls):
- super().setUpClass()
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
cls.picking = picking = cls._create_picking(
lines=[(cls.product_a, 10), (cls.product_b, 10)]
)
diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py
index 5a075d183a..e5d21d8fa3 100644
--- a/shopfloor/tests/test_checkout_list_package.py
+++ b/shopfloor/tests/test_checkout_list_package.py
@@ -90,8 +90,8 @@ def test_list_dest_package_error_no_package(self):
class CheckoutScanSetDestPackageCase(CheckoutCommonCase, SelectDestPackageMixin):
@classmethod
- def setUpClass(cls):
- super().setUpClass()
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
picking = cls._create_picking(
lines=[
(cls.product_a, 10),
diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py
index 58edd46ded..c708ec47ea 100644
--- a/shopfloor/tests/test_checkout_scan_package_action.py
+++ b/shopfloor/tests/test_checkout_scan_package_action.py
@@ -294,14 +294,18 @@ def test_scan_package_action_scan_packaging_ok(self):
move_line2.qty_done = move_line2.product_uom_qty
move_line3.qty_done = 0
- packaging = self.env["product.packaging"].create(
- {
- "name": "Pallet",
- "barcode": "PPP",
- "height": 12,
- "width": 13,
- "lngth": 14,
- }
+ packaging = (
+ self.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "name": "Pallet",
+ "barcode": "PPP",
+ "height": 12,
+ "width": 13,
+ "lngth": 14,
+ }
+ )
)
response = self.service.dispatch(
diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py
index c22c47cb93..442ab0ae14 100644
--- a/shopfloor/tests/test_checkout_select.py
+++ b/shopfloor/tests/test_checkout_select.py
@@ -25,8 +25,8 @@ def test_list_stock_picking(self):
class CheckoutSelectCase(CheckoutCommonCase):
@classmethod
- def setUpClass(cls):
- super().setUpClass()
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
cls.picking = cls._create_picking()
cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True)
cls.picking.action_assign()
@@ -52,7 +52,7 @@ def _test_error(self, picking, msg):
def test_select_error_not_found(self):
picking = self._create_picking()
- picking.unlink()
+ picking.sudo().unlink()
self._test_error(picking, "This transfer does not exist anymore.")
def test_select_error_not_available(self):
diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py
index dede5bfd37..c84d5bed9d 100644
--- a/shopfloor/tests/test_checkout_select_line.py
+++ b/shopfloor/tests/test_checkout_select_line.py
@@ -4,8 +4,8 @@
class CheckoutSelectLineCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
@classmethod
- def setUpClass(cls):
- super().setUpClass()
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
picking = cls._create_picking(
lines=[(cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10)]
)
diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py
index 39a6ee694f..5440e473d4 100644
--- a/shopfloor/tests/test_checkout_set_qty.py
+++ b/shopfloor/tests/test_checkout_set_qty.py
@@ -4,8 +4,8 @@
class CheckoutSetQtyCommonCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
@classmethod
- def setUpClass(cls):
- super().setUpClass()
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
picking = cls._create_picking(
lines=[(cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10)]
)
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index 3453ede428..2fd44ecb46 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -3,14 +3,18 @@
class ClusterPickingCommonCase(CommonCase, PickingBatchMixin):
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
- cls.wh.delivery_steps = "pick_pack_ship"
cls.picking_type = cls.menu.picking_type_ids
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.wh.sudo().delivery_steps = "pick_pack_ship"
+
def setUp(self):
super().setUp()
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
@@ -51,10 +55,10 @@ def _data_for_batch(self, batch, location, pack=None):
class ClusterPickingLineCommonCase(ClusterPickingCommonCase):
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
# quants already existing are from demo data
- cls.env["stock.quant"].search(
+ cls.env["stock.quant"].sudo().search(
[("location_id", "=", cls.stock_location.id)]
).unlink()
cls.batch = cls._create_picking_batch(
diff --git a/shopfloor/tests/test_cluster_picking_batch.py b/shopfloor/tests/test_cluster_picking_batch.py
index 114cd5354a..b7a2e915fc 100644
--- a/shopfloor/tests/test_cluster_picking_batch.py
+++ b/shopfloor/tests/test_cluster_picking_batch.py
@@ -3,19 +3,26 @@
class ClusterPickingBatchCase(CommonCase, PickingBatchMixin):
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- cls.product_a = cls.env["product.product"].create(
- {"name": "Product A", "type": "product"}
- )
- cls.product_b = cls.env["product.product"].create(
- {"name": "Product B", "type": "product"}
- )
- # which menu we pick should not matter for the batch picking api
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
cls.picking_type = cls.menu.picking_type_ids
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.product_a = (
+ cls.env["product.product"]
+ .sudo()
+ .create({"name": "Product A", "type": "product"})
+ )
+ cls.product_b = (
+ cls.env["product.product"]
+ .sudo()
+ .create({"name": "Product B", "type": "product"})
+ )
cls.batch1 = cls._create_picking_batch(
[[cls.BatchProduct(product=cls.product_a, quantity=1)]]
)
diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
index ed0f38d3d3..f809f19c8f 100644
--- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py
+++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
@@ -98,8 +98,8 @@ class ClusterPickingChangePackLotCase(ClusterPickingChangePackLotCommon):
"""Tests covering the /change_pack_lot endpoint"""
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
cls.batch = cls._create_picking_batch(
[[cls.BatchProduct(product=cls.product_a, quantity=10)]]
)
@@ -172,7 +172,9 @@ def test_change_pack_lot_change_pack_different_location(self):
line, [{"package_id": new_package.id, "result_package_id": new_package.id}]
)
self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
- # check that reservations have been updated
+ # check that reservations have been updated, the new package is not
+ # supposed to be in shelf2 anymore, and we should have no reserved qty
+ # for the initial package anymore
self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0)
self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
self.assert_quant_reserved_qty(
@@ -331,10 +333,6 @@ class ClusterPickingChangePackLotCaseSpecial(ClusterPickingChangePackLotCommon):
Special cases where we use a custom batch transfer
"""
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
-
def _create_picking_with_package_level(self, packages):
picking_form = Form(self.env["stock.picking"])
picking_form.partner_id = self.customer
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 3f54ab3984..353125ffb2 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -270,8 +270,8 @@ class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase):
"""
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
cls.batch = cls._create_picking_batch(
[
[
@@ -520,8 +520,8 @@ class ClusterPickingIsZeroCase(ClusterPickingCommonCase):
"""
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
cls.batch = cls._create_picking_batch(
[
[
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index 79e51fc58d..1b86cdf690 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -17,8 +17,8 @@ class ClusterPickingSelectionCase(ClusterPickingCommonCase):
"""
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
# drop base demo data and create our own batches to work with
cls.env["stock.picking.batch"].search([]).unlink()
cls.batch1 = cls._create_picking_batch(
@@ -254,8 +254,8 @@ class ClusterPickingSelectedCase(ClusterPickingCommonCase):
"""
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
cls.batch = cls._create_picking_batch(
[[cls.BatchProduct(product=cls.product_a, quantity=1)]]
)
diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py
index 8e1a004c10..41c612380a 100644
--- a/shopfloor/tests/test_cluster_picking_skip.py
+++ b/shopfloor/tests/test_cluster_picking_skip.py
@@ -6,10 +6,10 @@ class ClusterPickingSkipLineCase(ClusterPickingCommonCase):
"""
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
# quants already existing are from demo data
- cls.env["stock.quant"].search(
+ cls.env["stock.quant"].sudo().search(
[("location_id", "=", cls.stock_location.id)]
).unlink()
cls.batch = cls._create_picking_batch(
@@ -41,8 +41,8 @@ def test_skip_line(self):
self._simulate_batch_selected(self.batch, in_package=True)
# enforce names to have reliable sorting
- self.stock_location.name = "LOC2"
- self.shelf1.name = "LOC1"
+ self.stock_location.sudo().name = "LOC2"
+ self.shelf1.sudo().name = "LOC1"
all_lines = self.batch.picking_ids.move_line_ids
loc1_lines = all_lines.filtered(lambda line: (line.location_id == self.shelf1))
loc2_lines = all_lines.filtered(
diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py
index 1c7c7b12fb..8a2de6b921 100644
--- a/shopfloor/tests/test_cluster_picking_stock_issue.py
+++ b/shopfloor/tests/test_cluster_picking_stock_issue.py
@@ -6,11 +6,11 @@ class ClusterPickingStockIssue(ClusterPickingCommonCase):
"""
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
# quants already existing are from demo data
loc_ids = (cls.stock_location.id, cls.shelf1.id, cls.shelf2.id)
- cls.env["stock.quant"].search([("location_id", "in", loc_ids)]).unlink()
+ cls.env["stock.quant"].sudo().search([("location_id", "in", loc_ids)]).unlink()
cls.batch = cls._create_picking_batch(
[
[cls.BatchProduct(product=cls.product_a, quantity=10)],
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 2152b79d0c..6528eafe12 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -3,12 +3,12 @@
class ClusterPickingUnloadingCommonCase(ClusterPickingCommonCase):
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
# activate the computation of this field, so we have a chance to
# transition to the 'show completion info' popup.
- cls.picking_type.display_completion_info = True
+ cls.picking_type.sudo().display_completion_info = True
cls.batch = cls._create_picking_batch(
[
@@ -22,19 +22,27 @@ def setUpClass(cls, *args, **kwargs):
cls._simulate_batch_selected(cls.batch)
cls.bin1 = cls.env["stock.quant.package"].create({})
cls.bin2 = cls.env["stock.quant.package"].create({})
- cls.packing_a_location = cls.env["stock.location"].create(
- {
- "name": "Packing A",
- "barcode": "Packing-A",
- "location_id": cls.packing_location.id,
- }
+ cls.packing_a_location = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Packing A",
+ "barcode": "Packing-A",
+ "location_id": cls.packing_location.id,
+ }
+ )
)
- cls.packing_b_location = cls.env["stock.location"].create(
- {
- "name": "Packing B",
- "barcode": "Packing-B",
- "location_id": cls.packing_location.id,
- }
+ cls.packing_b_location = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Packing B",
+ "barcode": "Packing-B",
+ "location_id": cls.packing_location.id,
+ }
+ )
)
@@ -334,12 +342,6 @@ class ClusterPickingUnloadSplitCase(ClusterPickingUnloadingCommonCase):
screen even if the destinations are the same.
"""
- @classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- # this is what the /prepare_endpoint method would have set as all the
- # destinations are the same:
-
def test_unload_split_ok(self):
"""Call /unload_split and continue to unload single"""
move_lines = self.batch.mapped("picking_ids.move_line_ids")
@@ -373,8 +375,8 @@ class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase):
"""
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids")
cls._set_dest_package_and_done(cls.move_lines, cls.bin1)
cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id})
@@ -427,8 +429,8 @@ class ClusterPickingUnloadScanDestinationCase(ClusterPickingUnloadingCommonCase)
"""
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids")
cls.bin1_lines = cls.move_lines[:1]
cls.bin2_lines = cls.move_lines[1:]
@@ -696,7 +698,7 @@ def test_unload_scan_destination_completion_info(self):
"""/unload_scan_destination that make chained picking ready"""
picking = self.one_line_picking
dest_location = picking.move_line_ids.location_dest_id
- self.picking_type.display_completion_info = True
+ self.picking_type.sudo().display_completion_info = True
# create a chained picking after the current one
next_picking = picking.copy(
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
index 564960a4c4..a96ff00841 100644
--- a/shopfloor/tests/test_delivery_base.py
+++ b/shopfloor/tests/test_delivery_base.py
@@ -3,15 +3,18 @@
class DeliveryCommonCase(CommonCase):
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
-
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery")
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
- cls.wh.delivery_steps = "pick_pack_ship"
cls.picking_type = cls.menu.picking_type_ids
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.wh.sudo().delivery_steps = "pick_pack_ship"
+
def setUp(self):
super().setUp()
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py
index 0c0f9ba6a8..31ef016042 100644
--- a/shopfloor/tests/test_menu.py
+++ b/shopfloor/tests/test_menu.py
@@ -3,8 +3,8 @@
class MenuCase(CommonCase):
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
def setUp(self):
@@ -42,7 +42,7 @@ def test_menu_search(self):
def test_menu_search_restricted(self):
"""Request /menu/search with profile attributions"""
# Simulate the client searching menus
- menus = self.env["shopfloor.menu"].search([])
+ menus = self.env["shopfloor.menu"].sudo().search([])
menus_without_profile = menus[0:2]
# these menus should now be hidden for the current profile
other_profile = self.env.ref("shopfloor.shopfloor_profile_hb_truck_demo")
@@ -55,10 +55,12 @@ def test_menu_search_restricted(self):
def test_menu_search_warehouse_filter(self):
"""Request /menu/search with different warehouse on profile"""
- menus = self.env["shopfloor.menu"].search([])
+ menus = self.env["shopfloor.menu"].sudo().search([])
# should not be visible as the profile has another wh
menu_different_wh = menus[0]
- other_wh = self.env["stock.warehouse"].create({"name": "Test", "code": "test"})
+ other_wh = (
+ self.env["stock.warehouse"].sudo().create({"name": "Test", "code": "test"})
+ )
menu_different_wh.picking_type_ids.warehouse_id = other_wh
# should be visible to any profile
diff --git a/shopfloor/tests/test_openapi.py b/shopfloor/tests/test_openapi.py
index 1eae2f5569..913324c4a5 100644
--- a/shopfloor/tests/test_openapi.py
+++ b/shopfloor/tests/test_openapi.py
@@ -3,17 +3,14 @@
class TestOpenAPICommonCase(CommonCase):
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
# we don't really care about which menu and profile we use
# to read the OpenAPI specs
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_delivery")
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
- def setUp(self):
- super().setUp()
-
def test_openapi(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
services = work.many_components()
diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py
index c9c6fb21b7..cbfcff74b1 100644
--- a/shopfloor/tests/test_single_pack_putaway.py
+++ b/shopfloor/tests/test_single_pack_putaway.py
@@ -5,32 +5,42 @@
class SinglePackPutawayCase(CommonCase):
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- cls.product_a = cls.env["product.product"].create(
- {"name": "Product A", "type": "product"}
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck")
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.product_a = (
+ cls.env["product.product"]
+ .sudo()
+ .create({"name": "Product A", "type": "product"})
)
cls.pack_a = cls.env["stock.quant.package"].create(
{"location_id": cls.stock_location.id}
)
- cls.env["stock.putaway.rule"].create(
+ cls.env["stock.putaway.rule"].sudo().create(
{
"product_id": cls.product_a.id,
"location_in_id": cls.stock_location.id,
"location_out_id": cls.shelf1.id,
}
)
- cls.quant_a = cls.env["stock.quant"].create(
- {
- "product_id": cls.product_a.id,
- "location_id": cls.dispatch_location.id,
- "quantity": 1,
- "package_id": cls.pack_a.id,
- }
+ cls.quant_a = (
+ cls.env["stock.quant"]
+ .sudo()
+ .create(
+ {
+ "product_id": cls.product_a.id,
+ "location_id": cls.dispatch_location.id,
+ "quantity": 1,
+ "package_id": cls.pack_a.id,
+ }
+ )
)
- cls.menu = cls.env.ref("shopfloor.shopfloor_menu_put_away_reach_truck")
- cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
- cls.wh = cls.profile.warehouse_id
def setUp(self):
super().setUp()
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index bde20f97bc..bebde9dcc5 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -5,28 +5,36 @@
class SinglePackTransferCase(CommonCase):
@classmethod
- def setUpClass(cls, *args, **kwargs):
- super().setUpClass(*args, **kwargs)
- cls.pack_a = cls.env["stock.quant.package"].create(
- {"location_id": cls.stock_location.id}
- )
- cls.quant_a = cls.env["stock.quant"].create(
- {
- "product_id": cls.product_a.id,
- "location_id": cls.shelf1.id,
- "quantity": 1,
- "package_id": cls.pack_a.id,
- }
- )
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
cls.menu = cls.env.ref("shopfloor.shopfloor_menu_single_pallet_transfer")
cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
cls.wh = cls.profile.warehouse_id
cls.picking_type = cls.menu.picking_type_ids
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.pack_a = cls.env["stock.quant.package"].create(
+ {"location_id": cls.stock_location.id}
+ )
+ cls.quant_a = (
+ cls.env["stock.quant"]
+ .sudo()
+ .create(
+ {
+ "product_id": cls.product_a.id,
+ "location_id": cls.shelf1.id,
+ "quantity": 1,
+ "package_id": cls.pack_a.id,
+ }
+ )
+ )
cls.picking = cls._create_initial_move()
# disable the completion on the picking type, we'll have specific test(s)
# to check the behavior of this screen
- cls.picking_type.display_completion_info = False
+ cls.picking_type.sudo().display_completion_info = False
def setUp(self):
super().setUp()
@@ -251,7 +259,7 @@ def test_start_pack_from_location_several_packs(self):
pack_b = self.env["stock.quant.package"].create(
{"location_id": self.stock_location.id}
)
- self.env["stock.quant"].create(
+ self.env["stock.quant"].sudo().create(
{
"product_id": self.product_a.id,
"location_id": self.shelf1.id,
@@ -407,7 +415,7 @@ def test_validate_completion_info(self):
# activate the computation of this field, so we have a chance to
# transition to the 'show completion info' screen.
- self.picking_type.display_completion_info = True
+ self.picking_type.sudo().display_completion_info = True
# create a chained picking after the current one
next_picking = self.picking.copy(
From 335a2e795ea201d904f6b8836efa94d4df8d3cf3 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 26 May 2020 15:08:57 +0200
Subject: [PATCH 228/940] cluster picking: Add picking_batch_id in every method
call
So we can always handle a move line deleted on the backend by
taking the next move line of the batch instead of aborting.
---
shopfloor/services/cluster_picking.py | 92 +++++++++++--------
.../test_cluster_picking_change_pack_lot.py | 13 ++-
shopfloor/tests/test_cluster_picking_scan.py | 44 ++++++++-
shopfloor/tests/test_cluster_picking_skip.py | 5 +-
.../tests/test_cluster_picking_stock_issue.py | 4 +-
5 files changed, 112 insertions(+), 46 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 8ad83d8ac1..881b56931e 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -120,11 +120,12 @@ def _response_for_change_pack_lot(self, move_line, message=None):
message=message,
)
- def _response_for_zero_check(self, move_line):
+ def _response_for_zero_check(self, batch, move_line):
data = {
"id": move_line.id,
"location_src": self.data_struct.location(move_line.location_id),
}
+ data["batch"] = self.data_struct.picking_batch(batch)
return self._response(next_state="zero_check", data=data)
def _response_for_unload_all(self, batch, message=None):
@@ -312,10 +313,10 @@ def confirm_start(self, picking_batch_id):
package
* start: if the condition above is wrong (rare case of race condition...)
"""
- picking_batch = self.env["stock.picking.batch"].browse(picking_batch_id)
- if not picking_batch.exists():
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
return self._response_batch_does_not_exist()
- return self._pick_next_line(picking_batch)
+ return self._pick_next_line(batch)
def _pick_next_line(self, batch, message=None, force_line=None):
if force_line:
@@ -404,7 +405,7 @@ def unassign(self, picking_batch_id):
batch.write({"state": "draft", "user_id": False})
return self._response_for_start()
- def scan_line(self, move_line_id, barcode):
+ def scan_line(self, picking_batch_id, move_line_id, barcode):
"""Scan a location, a pack, a product or a lots
There is no side-effect, it is only to check that the operator takes
@@ -427,10 +428,13 @@ def scan_line(self, move_line_id, barcode):
pack meanwhile (race condition).
* scan_destination: if the barcode matches.
"""
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
+ return self._response_batch_does_not_exist()
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- return self._response_for_start(
- message=self.msg_store.unrecoverable_error()
+ return self._pick_next_line(
+ batch, message=self.msg_store.operation_not_found()
)
search = self.actions_for("search")
@@ -543,7 +547,7 @@ def _scan_line_by_location(self, picking, move_line, location):
return self._response_for_scan_destination(move_line)
- def scan_destination_pack(self, move_line_id, barcode, quantity):
+ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantity):
"""Scan the destination package (bin) for a move line
If the quantity picked (passed to the endpoint) is < expected quantity,
@@ -563,10 +567,13 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
have the same destination.
* start_line: to pick the next line if any.
"""
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
+ return self._response_batch_does_not_exist()
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- return self._response_for_start(
- message=self.msg_store.unrecoverable_error()
+ return self._pick_next_line(
+ batch, message=self.msg_store.operation_not_found()
)
# store a new line if we have split our line (not enough qty)
@@ -619,12 +626,10 @@ def scan_destination_pack(self, move_line_id, barcode, quantity):
)
move_line.write({"qty_done": quantity, "result_package_id": bin_package.id})
- batch = move_line.picking_id.batch_id
-
if self._planned_qty_in_location_is_empty(
move_line.product_id, move_line.location_id
):
- return self._response_for_zero_check(move_line)
+ return self._response_for_zero_check(batch, move_line)
return self._pick_next_line(
batch,
@@ -725,7 +730,7 @@ def _next_bin_package_for_unload_single(self, batch):
packages = self._bin_packages_to_unload(batch)
return fields.first(packages)
- def is_zero(self, move_line_id, zero):
+ def is_zero(self, picking_batch_id, move_line_id, zero):
"""Confirm or not if the source location of a move has zero qty
If the user confirms there is zero quantity, it means the stock was
@@ -739,10 +744,13 @@ def is_zero(self, move_line_id, zero):
* unload_single: if all lines have a destination package and different
destination
"""
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
+ return self._response_batch_does_not_exist()
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- return self._response_for_start(
- message=self.msg_store.unrecoverable_error()
+ return self._pick_next_line(
+ batch, message=self.msg_store.operation_not_found()
)
if not zero:
@@ -753,7 +761,6 @@ def is_zero(self, move_line_id, zero):
ref=move_line.picking_id.name,
)
- batch = move_line.picking_id.batch_id
return self._pick_next_line(
batch,
message=self.msg_store.x_units_put_in_package(
@@ -761,7 +768,7 @@ def is_zero(self, move_line_id, zero):
),
)
- def skip_line(self, move_line_id):
+ def skip_line(self, picking_batch_id, move_line_id):
"""Skip a line. The line will be processed at the end.
It adds a flag on the move line, when the next line to pick
@@ -773,10 +780,13 @@ def skip_line(self, move_line_id):
* start_line: with data for the next line (or itself if it's the last one,
in such case, a helpful message is returned)
"""
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
+ return self._response_batch_does_not_exist()
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- return self._response_for_start(
- message=self.msg_store.unrecoverable_error()
+ return self._pick_next_line(
+ batch, message=self.msg_store.operation_not_found()
)
# flag as postponed
move_line.shopfloor_postponed = True
@@ -786,7 +796,7 @@ def _pick_after_skip_line(self, move_line):
batch = move_line.picking_id.batch_id
return self._pick_next_line(batch)
- def stock_issue(self, move_line_id):
+ def stock_issue(self, picking_batch_id, move_line_id):
"""Declare a stock issue for a line
After errors in the stock, the user cannot take all the products
@@ -815,12 +825,14 @@ def stock_issue(self, move_line_id):
and the last line has a stock issue). In this case, this method *has*
to handle the closing of the batch to create backorders (_unload_end)
"""
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
+ return self._response_batch_does_not_exist()
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- return self._response(
- next_state="start", message=self.msg_store.unrecoverable_error()
+ return self._pick_next_line(
+ batch, message=self.msg_store.operation_not_found()
)
- batch = move_line.picking_id.batch_id
inventory = self.actions_for("inventory")
# create a draft inventory for a user to check
@@ -876,7 +888,7 @@ def _domain_stock_issue_unlink_lines(self, move_line):
]
return domain
- def change_pack_lot(self, move_line_id, barcode):
+ def change_pack_lot(self, picking_batch_id, move_line_id, barcode):
"""Change the expected pack or the lot for a line
If the expected lot is at the very bottom of the location or a stock
@@ -894,10 +906,13 @@ def change_pack_lot(self, move_line_id, barcode):
* scan_destination: the pack or the lot could be changed
* change_pack_lot: any error occurred during the change
"""
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
+ if not batch.exists():
+ return self._response_batch_does_not_exist()
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
- return self._response(
- next_state="start", message=self.msg_store.unrecoverable_error()
+ return self._pick_next_line(
+ batch, message=self.msg_store.operation_not_found()
)
search = self.actions_for("search")
lot = search.lot_from_scan(barcode)
@@ -1316,12 +1331,14 @@ def unassign(self):
def scan_line(self):
return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
"barcode": {"required": True, "type": "string"},
}
def scan_destination_pack(self):
return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
"barcode": {"required": True, "type": "string"},
"quantity": {"coerce": to_float, "required": True, "type": "float"},
@@ -1334,18 +1351,26 @@ def prepare_unload(self):
def is_zero(self):
return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
"zero": {"coerce": to_bool, "required": True, "type": "boolean"},
}
def skip_line(self):
- return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}}
+ return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
def stock_issue(self):
- return {"move_line_id": {"coerce": to_int, "required": True, "type": "integer"}}
+ return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
def change_pack_lot(self):
return {
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
"barcode": {"required": True, "type": "string"},
}
@@ -1362,11 +1387,6 @@ def unload_split(self):
"picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
}
- def unload_router(self):
- return {
- "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
- }
-
def unload_scan_pack(self):
return {
"picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
@@ -1527,9 +1547,6 @@ def set_destination_all(self):
def unload_split(self):
return self._response_schema(next_states={"unload_single"})
- def unload_router(self):
- return self._response_schema(next_states={"unload_single", "start_line"})
-
def unload_scan_pack(self):
return self._response_schema(
next_states={
@@ -1584,6 +1601,7 @@ def _schema_for_zero_check(self):
"id": {"required": True, "type": "integer"},
}
schema["location_src"] = self.schemas._schema_dict_of(self.schemas.location())
+ schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch())
return schema
@property
diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
index f809f19c8f..f68772726b 100644
--- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py
+++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
@@ -34,8 +34,14 @@ def _create_lot(self, product):
)
def _test_change_pack_lot(self, line, barcode, success=True, message=None):
+ batch = line.picking_id.batch_id
response = self.service.dispatch(
- "change_pack_lot", params={"move_line_id": line.id, "barcode": barcode},
+ "change_pack_lot",
+ params={
+ "picking_batch_id": batch.id,
+ "move_line_id": line.id,
+ "barcode": barcode,
+ },
)
if success:
self.assert_response(
@@ -53,7 +59,10 @@ def _test_change_pack_lot(self, line, barcode, success=True, message=None):
)
def _skip_line(self, line, next_line=None):
- response = self.service.dispatch("skip_line", params={"move_line_id": line.id})
+ batch = line.picking_id.batch_id
+ response = self.service.dispatch(
+ "skip_line", params={"picking_batch_id": batch.id, "move_line_id": line.id}
+ )
if next_line:
self.assert_response(
response, next_state="start_line", data=self._line_data(next_line)
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 353125ffb2..b67471d560 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -15,16 +15,28 @@ class ClusterPickingScanLineCase(ClusterPickingLineCommonCase):
"""
def _scan_line_ok(self, line, scanned):
+ batch = line.picking_id.batch_id
response = self.service.dispatch(
- "scan_line", params={"move_line_id": line.id, "barcode": scanned}
+ "scan_line",
+ params={
+ "picking_batch_id": batch.id,
+ "move_line_id": line.id,
+ "barcode": scanned,
+ },
)
self.assert_response(
response, next_state="scan_destination", data=self._line_data(line)
)
def _scan_line_error(self, line, scanned, message):
+ batch = line.picking_id.batch_id
response = self.service.dispatch(
- "scan_line", params={"move_line_id": line.id, "barcode": scanned}
+ "scan_line",
+ params={
+ "picking_batch_id": batch.id,
+ "move_line_id": line.id,
+ "barcode": scanned,
+ },
)
self.assert_response(
response,
@@ -300,6 +312,7 @@ def test_scan_destination_pack_ok(self):
response = self.service.dispatch(
"scan_destination_pack",
params={
+ "picking_batch_id": self.batch.id,
"move_line_id": line.id,
"barcode": self.bin1.name,
"quantity": qty_done,
@@ -335,6 +348,7 @@ def test_scan_destination_pack_ok_last_line(self):
response = self.service.dispatch(
"scan_destination_pack",
params={
+ "picking_batch_id": self.batch.id,
"move_line_id": line.id,
"barcode": self.bin2.name,
"quantity": qty_done,
@@ -360,6 +374,7 @@ def test_scan_destination_pack_not_empty_same_picking(self):
response = self.service.dispatch(
"scan_destination_pack",
params={
+ "picking_batch_id": self.batch.id,
"move_line_id": line2.id,
# this bin is used for the same picking, should be allowed
"barcode": self.bin1.name,
@@ -383,6 +398,7 @@ def test_scan_destination_pack_not_empty_different_picking(self):
response = self.service.dispatch(
"scan_destination_pack",
params={
+ "picking_batch_id": self.batch.id,
"move_line_id": line.id,
# this bin is used for the other picking
"barcode": self.bin1.name,
@@ -407,6 +423,7 @@ def test_scan_destination_pack_bin_not_found(self):
response = self.service.dispatch(
"scan_destination_pack",
params={
+ "picking_batch_id": self.batch.id,
"move_line_id": line.id,
# this bin is used for the other picking
"barcode": "⌿",
@@ -429,6 +446,7 @@ def test_scan_destination_pack_quantity_more(self):
response = self.service.dispatch(
"scan_destination_pack",
params={
+ "picking_batch_id": self.batch.id,
"move_line_id": line.id,
"barcode": self.bin1.name,
"quantity": line.product_uom_qty + 1,
@@ -455,6 +473,7 @@ def test_scan_destination_pack_quantity_less(self):
response = self.service.dispatch(
"scan_destination_pack",
params={
+ "picking_batch_id": self.batch.id,
"move_line_id": line.id,
"barcode": self.bin1.name,
"quantity": line.product_uom_qty - 3,
@@ -495,6 +514,7 @@ def test_scan_destination_pack_zero_check(self):
response = self.service.dispatch(
"scan_destination_pack",
params={
+ "picking_batch_id": self.batch.id,
"move_line_id": line.id,
"barcode": self.bin1.name,
"quantity": line.product_uom_qty,
@@ -504,7 +524,11 @@ def test_scan_destination_pack_zero_check(self):
self.assert_response(
response,
next_state="zero_check",
- data={"id": line.id, "location_src": self.data.location(line.location_id)},
+ data={
+ "id": line.id,
+ "location_src": self.data.location(line.location_id),
+ "batch": self.data.picking_batch(self.batch),
+ },
)
@@ -546,7 +570,12 @@ def setUpClassBaseData(cls, *args, **kwargs):
def test_is_zero_is_empty(self):
"""call /is_zero confirming it's empty"""
response = self.service.dispatch(
- "is_zero", params={"move_line_id": self.line.id, "zero": True}
+ "is_zero",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": self.line.id,
+ "zero": True,
+ },
)
self.assert_response(
response,
@@ -565,7 +594,12 @@ def test_is_zero_is_empty(self):
def test_is_zero_is_not_empty(self):
"""call /is_zero not confirming it's empty"""
response = self.service.dispatch(
- "is_zero", params={"move_line_id": self.line.id, "zero": False}
+ "is_zero",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": self.line.id,
+ "zero": False,
+ },
)
inventory = self.env["stock.inventory"].search(
[
diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py
index 41c612380a..a0054f741e 100644
--- a/shopfloor/tests/test_cluster_picking_skip.py
+++ b/shopfloor/tests/test_cluster_picking_skip.py
@@ -26,7 +26,10 @@ def setUpClassBaseData(cls, *args, **kwargs):
)
def _skip_line(self, line, next_line=None):
- response = self.service.dispatch("skip_line", params={"move_line_id": line.id})
+ response = self.service.dispatch(
+ "skip_line",
+ params={"picking_batch_id": self.batch.id, "move_line_id": line.id},
+ )
if next_line:
self.assert_response(
response, next_state="start_line", data=self._line_data(next_line)
diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py
index 8a2de6b921..8d1eaa0a83 100644
--- a/shopfloor/tests/test_cluster_picking_stock_issue.py
+++ b/shopfloor/tests/test_cluster_picking_stock_issue.py
@@ -29,8 +29,10 @@ def setUpClassBaseData(cls, *args, **kwargs):
cls.dest_package = cls.env["stock.quant.package"].create({})
def _stock_issue(self, line, next_line_func=None):
+ batch = line.picking_id.batch_id
response = self.service.dispatch(
- "stock_issue", params={"move_line_id": line.id}
+ "stock_issue",
+ params={"picking_batch_id": batch.id, "move_line_id": line.id},
)
# use a function/lambda to delay the read of the next line,
# when calling _stock_issue(), the move_line may not exist and
From a2c165edfe7ab23d445f051ad631ac8476d70efd Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 26 May 2020 16:03:21 +0200
Subject: [PATCH 229/940] backend rest api: make schema dict/list required by
default
But nullable. If a key is missing and the schema is required by default,
the test will fail so we can decide either to add the missing data
either to pass required=False to the schema builder.
---
shopfloor/services/schema.py | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 999c78c658..6e97fa3525 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -15,12 +15,14 @@ class BaseShopfloorSchemaResponse(Component):
_is_rest_service_component = False
def _schema_list_of(self, schema, **kw):
- return {
+ schema = {
"type": "list",
"nullable": True,
- "required": False,
+ "required": True,
"schema": {"type": "dict", "schema": schema},
}
+ schema.update(kw)
+ return schema
def _simple_record(self):
return {
@@ -32,7 +34,7 @@ def _schema_dict_of(self, schema, **kw):
schema = {
"type": "dict",
"nullable": True,
- "required": False,
+ "required": True,
"schema": schema,
}
schema.update(kw)
@@ -148,7 +150,7 @@ def packaging(self):
"name": {"type": "string", "nullable": False, "required": True},
}
- def picking_batch(self, with_pickings=True):
+ def picking_batch(self, with_pickings=False):
schema = {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
From 8ebe2133cff8ff64f43c1753129eda20067cf746 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 26 May 2020 16:02:58 +0200
Subject: [PATCH 230/940] backend: Hide SF fields on move lines by default
---
shopfloor/views/stock_move_line.xml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/shopfloor/views/stock_move_line.xml b/shopfloor/views/stock_move_line.xml
index 5b970af673..67ee61d002 100644
--- a/shopfloor/views/stock_move_line.xml
+++ b/shopfloor/views/stock_move_line.xml
@@ -14,16 +14,19 @@
From 224964a3b2d3e145882c95ace3c088274e1834ba Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 26 May 2020 11:34:50 +0200
Subject: [PATCH 231/940] backend: add move line prod qty by packaging
---
shopfloor/__manifest__.py | 3 ++
shopfloor/actions/data.py | 6 +++-
shopfloor/services/schema.py | 52 ++++++++++++----------------
shopfloor/tests/common.py | 4 +++
shopfloor/tests/test_actions_data.py | 20 ++++++++++-
5 files changed, 53 insertions(+), 32 deletions(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index f9e2462919..7e0f0bddc7 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -35,6 +35,9 @@
# TODO: used for picking.carrier_id detail info.
# This must be an optional dep
"delivery",
+ # TODO: used for calculating qty by packaging to pick
+ # This must be an optional dep
+ "stock_packaging_calculator",
],
"data": [
"security/ir.model.access.csv",
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 0d987e0da0..b16a6538fe 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -112,7 +112,7 @@ def lots(self, record, **kw):
def _lot_parser(self):
return ["id", "name", "ref"]
- def move_line(self, record, **kw):
+ def move_line(self, record, qty_by_packaging=False, **kw):
record = record.with_context(location=record.location_id.id)
data = self._jsonify(record, self._move_line_parser)
if data:
@@ -128,6 +128,10 @@ def move_line(self, record, **kw):
),
}
)
+ if qty_by_packaging:
+ data["qty_by_packaging"] = record.product_id.product_qty_by_packaging(
+ record.product_uom_qty
+ )
return data
def move_lines(self, records, **kw):
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 6e97fa3525..868821378e 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -69,41 +69,33 @@ def picking(self):
},
}
- def move_line(self, with_packaging=False):
- return {
+ def move_line(self, with_packaging=False, qty_by_packaging=False):
+ schema = {
"id": {"type": "integer", "required": True},
"qty_done": {"type": "float", "required": True},
"quantity": {"type": "float", "required": True},
- "product": {"type": "dict", "required": True, "schema": self.product()},
- "lot": {
- "type": "dict",
- "required": False,
- "nullable": True,
- "schema": self.lot(),
- },
- "package_src": {
- "type": "dict",
- "required": True,
+ "product": self._schema_dict_of(self.product(), required=True),
+ "lot": self._schema_dict_of(self.lot()),
+ "package_src": self._schema_dict_of(
+ self.package(with_packaging=with_packaging), required=True
+ ),
+ "package_dest": self._schema_dict_of(
+ self.package(with_packaging=with_packaging)
+ ),
+ "location_src": self._schema_dict_of(
+ self.location(), required=True, nullable=False
+ ),
+ "location_dest": self._schema_dict_of(
+ self.location(), required=True, nullable=False
+ ),
+ }
+ if qty_by_packaging:
+ schema["qty_by_packaging"] = {
+ "type": "list",
"nullable": True,
- "schema": self.package(with_packaging=with_packaging),
- },
- "package_dest": {
- "type": "dict",
"required": False,
- "nullable": True,
- "schema": self.package(with_packaging=with_packaging),
- },
- "location_src": {
- "type": "dict",
- "required": True,
- "schema": self.location(),
- },
- "location_dest": {
- "type": "dict",
- "required": True,
- "schema": self.location(),
- },
- }
+ }
+ return schema
def product(self):
return {
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index b83f076c0b..c5a933997b 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -138,6 +138,7 @@ def setUpClassBaseData(cls):
"name": "Box",
"product_id": cls.product_a.id,
"barcode": "ProductABox",
+ "qty": 40,
}
)
)
@@ -162,6 +163,7 @@ def setUpClassBaseData(cls):
"name": "Box",
"product_id": cls.product_b.id,
"barcode": "ProductBBox",
+ "qty": 30,
}
)
)
@@ -186,6 +188,7 @@ def setUpClassBaseData(cls):
"name": "Box",
"product_id": cls.product_b.id,
"barcode": "ProductCBox",
+ "qty": 20,
}
)
)
@@ -210,6 +213,7 @@ def setUpClassBaseData(cls):
"name": "Box",
"product_id": cls.product_d.id,
"barcode": "ProductDBox",
+ "qty": 10,
}
)
)
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index d0612fe1ee..55fbb39b54 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -29,7 +29,7 @@ def setUpClassBaseData(cls):
(cls.product_a, 10),
(cls.product_b, 10),
(cls.product_c, 10),
- (cls.product_d, 10),
+ (cls.product_d, 25),
]
)
# put product A in a package
@@ -231,6 +231,24 @@ def test_data_move_line_raw(self):
}
self.assertDictEqual(data, expected)
+ def test_data_move_line_with_qty_by_packaging(self):
+ move_line = self.move_d.move_line_ids
+ data = self.data.move_line(move_line, qty_by_packaging=True)
+ self.assert_schema(self.schema.move_line(qty_by_packaging=True), data)
+ expected = {
+ "id": move_line.id,
+ "qty_done": 0.0,
+ "quantity": move_line.product_uom_qty,
+ "product": self._expected_product(self.product_d),
+ "lot": None,
+ "package_src": None,
+ "package_dest": None,
+ "location_src": self._expected_location(move_line.location_id),
+ "location_dest": self._expected_location(move_line.location_dest_id),
+ "qty_by_packaging": [(2, "Box"), (5, "Units")],
+ }
+ self.assertDictEqual(data, expected)
+
class ActionsDataCaseBatchPicking(ActionsDataCaseBase, PickingBatchMixin):
@classmethod
From d3e294ed2f5130b0ed464534977805de03a6d350 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 27 May 2020 09:13:59 +0200
Subject: [PATCH 232/940] Add docstring in base test class
---
shopfloor/tests/common.py | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index c5a933997b..f508b8b9bc 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -25,11 +25,29 @@ def __eq__(self, other):
class CommonCase(SavepointCase, ComponentMixin):
+ """Base class for writing Shopfloor tests
+
+ All tests are run as normal stock user by default, to check that all the
+ services work without manager permissions.
+
+ The consequences on writing tests:
+
+ * Records created or written in a test setup must use sudo()
+ if the user has no permission on these models.
+ * Tests setUps should not extend setUpClass but setUpClassVars
+ and setUpClassBaseData, which already have an environment using
+ the stock user.
+ * Be wary of creating records before setUpClassUsers is called, because
+ it their "env.user" would be admin and could lead to inconsistencies
+ in tests.
+
+ This class provides several helpers which are used throughout all the tests.
+ """
# by default disable tracking suite-wise, it's a time saver :)
tracking_disable = True
- ANY = AnyObject()
+ ANY = AnyObject() # allow accepting anything in assert_response()
maxDiff = None
From f2a080c69dc2c89acb6c64be412530a5b46ba8fc Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 27 May 2020 09:34:05 +0200
Subject: [PATCH 233/940] backend: add qty by packaging to cluster picking
---
shopfloor/services/cluster_picking.py | 11 ++++-----
shopfloor/tests/test_actions_data.py | 2 +-
shopfloor/tests/test_actions_data_detail.py | 2 +-
shopfloor/tests/test_cluster_picking_base.py | 8 +++----
.../test_cluster_picking_change_pack_lot.py | 4 ++--
shopfloor/tests/test_cluster_picking_scan.py | 24 +++++++++++--------
.../tests/test_cluster_picking_select.py | 2 +-
shopfloor/tests/test_cluster_picking_skip.py | 4 +++-
.../tests/test_cluster_picking_stock_issue.py | 2 +-
.../tests/test_cluster_picking_unload.py | 2 +-
10 files changed, 33 insertions(+), 28 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 881b56931e..05a485a5bf 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -98,13 +98,13 @@ def _response_for_manual_selection(self, batches, message=None):
def _response_for_start_line(self, move_line, message=None, popup=None):
return self._response(
next_state="start_line",
- data=self._data_move_line(move_line),
+ data=self._data_move_line(move_line, qty_by_packaging=True),
message=message,
popup=popup,
)
def _response_for_scan_destination(self, move_line, message=None):
- data = self._data_move_line(move_line)
+ data = self._data_move_line(move_line, qty_by_packaging=True)
last_picked_line = self._last_picked_line(move_line.picking_id)
if last_picked_line:
# suggest pack to be used for the next line
@@ -116,7 +116,7 @@ def _response_for_scan_destination(self, move_line, message=None):
def _response_for_change_pack_lot(self, move_line, message=None):
return self._response(
next_state="change_pack_lot",
- data=self._data_move_line(move_line),
+ data=self._data_move_line(move_line, qty_by_packaging=True),
message=message,
)
@@ -379,7 +379,7 @@ def _data_move_line(self, line, **kw):
picking = line.picking_id
batch = picking.batch_id
product = line.product_id
- data = self.data_struct.move_line(line)
+ data = self.data_struct.move_line(line, **kw)
# additional values
# Ensure destination pack is never proposed on the frontend.
# This should happen only as proposal on `scan_destination`
@@ -391,7 +391,6 @@ def _data_move_line(self, line, **kw):
data["product"]["qty_available"] = product.with_context(
location=line.location_id.id
).qty_available
- data.update(kw)
return data
def unassign(self, picking_batch_id):
@@ -1577,7 +1576,7 @@ def _schema_for_batch_details(self):
@property
def _schema_for_single_line_details(self):
- schema = self.schemas.move_line()
+ schema = self.schemas.move_line(qty_by_packaging=True)
schema["picking"] = self.schemas._schema_dict_of(self.schemas.picking())
schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch())
return schema
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 55fbb39b54..f5482d075d 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -122,7 +122,7 @@ def test_data_picking(self):
"name": self.picking.name,
"note": "read me",
"origin": "created by test",
- "weight": 110.0,
+ "weight": 155.0,
"partner": {"id": self.customer.id, "name": self.customer.name},
}
self.assertDictEqual(data, expected)
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index 6f3c85d62d..81be39f882 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -171,7 +171,7 @@ def test_data_picking(self):
"name": picking.name,
"note": "read me",
"origin": "created by test",
- "weight": 110.0,
+ "weight": 155.0,
"partner": {"id": self.customer.id, "name": self.customer.name},
"priority": "Very Urgent",
"operation_type": {
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index 2fd44ecb46..57ad3b68cc 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -20,11 +20,11 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="cluster_picking")
- def _line_data(self, move_line, qty=None, package_dest=False):
+ def _line_data(self, move_line, qty=None, package_dest=False, **kw):
picking = move_line.picking_id
# A package exists on the move line, because the quant created
# by ``_simulate_batch_selected`` has a package.
- data = self.data.move_line(move_line)
+ data = self.data.move_line(move_line, **kw)
if not package_dest:
data["package_dest"] = None
if qty:
@@ -65,6 +65,6 @@ def setUpClassBaseData(cls, *args, **kwargs):
[[cls.BatchProduct(product=cls.product_a, quantity=1)]]
)
- def _line_data(self, move_line, qty=1.0):
+ def _line_data(self, move_line, qty=1.0, **kw):
# just force qty to 1.0
- return super()._line_data(move_line, qty=qty)
+ return super()._line_data(move_line, qty=qty, **kw)
diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
index f68772726b..37a6f6be64 100644
--- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py
+++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
@@ -48,14 +48,14 @@ def _test_change_pack_lot(self, line, barcode, success=True, message=None):
response,
message=message,
next_state="scan_destination",
- data=self._line_data(line),
+ data=self._line_data(line, qty_by_packaging=True),
)
else:
self.assert_response(
response,
message=message,
next_state="change_pack_lot",
- data=self._line_data(line),
+ data=self._line_data(line, qty_by_packaging=True),
)
def _skip_line(self, line, next_line=None):
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index b67471d560..ec6494967b 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -25,7 +25,9 @@ def _scan_line_ok(self, line, scanned):
},
)
self.assert_response(
- response, next_state="scan_destination", data=self._line_data(line)
+ response,
+ next_state="scan_destination",
+ data=self._line_data(line, qty_by_packaging=True),
)
def _scan_line_error(self, line, scanned, message):
@@ -41,7 +43,7 @@ def _scan_line_error(self, line, scanned, message):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(line),
+ data=self._line_data(line, qty_by_packaging=True),
message=message,
)
@@ -324,7 +326,7 @@ def test_scan_destination_pack_ok(self):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(next_line),
+ data=self._line_data(next_line, qty_by_packaging=True),
message={
"message_type": "success",
"body": "{} {} put in {}".format(
@@ -385,7 +387,9 @@ def test_scan_destination_pack_not_empty_same_picking(self):
response,
next_state="start_line",
# we did not pick this line, so it should go there
- data=self._line_data(self.one_line_picking.move_line_ids),
+ data=self._line_data(
+ self.one_line_picking.move_line_ids, qty_by_packaging=True
+ ),
message=self.ANY,
)
@@ -409,7 +413,7 @@ def test_scan_destination_pack_not_empty_different_picking(self):
self.assert_response(
response,
next_state="scan_destination",
- data=self._line_data(line),
+ data=self._line_data(line, qty_by_packaging=True),
message={
"message_type": "error",
"body": "The destination bin {} is not empty,"
@@ -433,7 +437,7 @@ def test_scan_destination_pack_bin_not_found(self):
self.assert_response(
response,
next_state="scan_destination",
- data=self._line_data(line),
+ data=self._line_data(line, qty_by_packaging=True),
message={
"message_type": "error",
"body": "Bin {} doesn't exist".format("⌿"),
@@ -455,7 +459,7 @@ def test_scan_destination_pack_quantity_more(self):
self.assert_response(
response,
next_state="scan_destination",
- data=self._line_data(line),
+ data=self._line_data(line, qty_by_packaging=True),
message={
"message_type": "error",
"body": "You must not pick more than {} units.".format(
@@ -493,7 +497,7 @@ def test_scan_destination_pack_quantity_less(self):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(new_line),
+ data=self._line_data(new_line, qty_by_packaging=True),
message={
"message_type": "success",
"body": "{} {} put in {}".format(
@@ -580,7 +584,7 @@ def test_is_zero_is_empty(self):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(self.next_line),
+ data=self._line_data(self.next_line, qty_by_packaging=True),
message={
"message_type": "success",
"body": "{} {} put in {}".format(
@@ -616,7 +620,7 @@ def test_is_zero_is_not_empty(self):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(self.next_line),
+ data=self._line_data(self.next_line, qty_by_packaging=True),
message={
"message_type": "success",
"body": "{} {} put in {}".format(
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index 1b86cdf690..e74b272247 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -277,7 +277,7 @@ def test_confirm_start_ok(self):
response = self.service.dispatch(
"confirm_start", params={"picking_batch_id": self.batch.id}
)
- data = self.data.move_line(first_move_line)
+ data = self.data.move_line(first_move_line, qty_by_packaging=True)
data["package_dest"] = None
data["picking"] = self.data.picking(picking)
data["batch"] = self.data.picking_batch(batch)
diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py
index a0054f741e..9ebfb12dac 100644
--- a/shopfloor/tests/test_cluster_picking_skip.py
+++ b/shopfloor/tests/test_cluster_picking_skip.py
@@ -32,7 +32,9 @@ def _skip_line(self, line, next_line=None):
)
if next_line:
self.assert_response(
- response, next_state="start_line", data=self._line_data(next_line)
+ response,
+ next_state="start_line",
+ data=self._line_data(next_line, qty_by_packaging=True),
)
return response
diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py
index 8d1eaa0a83..ce08643e3e 100644
--- a/shopfloor/tests/test_cluster_picking_stock_issue.py
+++ b/shopfloor/tests/test_cluster_picking_stock_issue.py
@@ -41,7 +41,7 @@ def _stock_issue(self, line, next_line_func=None):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(next_line_func()),
+ data=self._line_data(next_line_func(), qty_by_packaging=True),
)
else:
self.assert_response(
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 6528eafe12..b91922591e 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -213,7 +213,7 @@ def test_set_destination_all_remaining_lines(self):
# the remaining move line still needs to be picked
response,
next_state="start_line",
- data=self._line_data(move_lines[2]),
+ data=self._line_data(move_lines[2], qty_by_packaging=True),
message={"body": "Batch Transfer line done", "message_type": "success"},
)
From b9cb74c91a8c57d4a60e0eb6946e78eb8a7a2a8b Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 27 May 2020 12:53:47 +0200
Subject: [PATCH 234/940] Revert "backend: add qty by packaging to cluster
picking"
This reverts commit f16d585789e906b281bed919af033b4537f30d6d.
---
shopfloor/services/cluster_picking.py | 11 +++++----
shopfloor/tests/test_actions_data.py | 2 +-
shopfloor/tests/test_actions_data_detail.py | 2 +-
shopfloor/tests/test_cluster_picking_base.py | 8 +++----
.../test_cluster_picking_change_pack_lot.py | 4 ++--
shopfloor/tests/test_cluster_picking_scan.py | 24 ++++++++-----------
.../tests/test_cluster_picking_select.py | 2 +-
shopfloor/tests/test_cluster_picking_skip.py | 4 +---
.../tests/test_cluster_picking_stock_issue.py | 2 +-
.../tests/test_cluster_picking_unload.py | 2 +-
10 files changed, 28 insertions(+), 33 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 05a485a5bf..881b56931e 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -98,13 +98,13 @@ def _response_for_manual_selection(self, batches, message=None):
def _response_for_start_line(self, move_line, message=None, popup=None):
return self._response(
next_state="start_line",
- data=self._data_move_line(move_line, qty_by_packaging=True),
+ data=self._data_move_line(move_line),
message=message,
popup=popup,
)
def _response_for_scan_destination(self, move_line, message=None):
- data = self._data_move_line(move_line, qty_by_packaging=True)
+ data = self._data_move_line(move_line)
last_picked_line = self._last_picked_line(move_line.picking_id)
if last_picked_line:
# suggest pack to be used for the next line
@@ -116,7 +116,7 @@ def _response_for_scan_destination(self, move_line, message=None):
def _response_for_change_pack_lot(self, move_line, message=None):
return self._response(
next_state="change_pack_lot",
- data=self._data_move_line(move_line, qty_by_packaging=True),
+ data=self._data_move_line(move_line),
message=message,
)
@@ -379,7 +379,7 @@ def _data_move_line(self, line, **kw):
picking = line.picking_id
batch = picking.batch_id
product = line.product_id
- data = self.data_struct.move_line(line, **kw)
+ data = self.data_struct.move_line(line)
# additional values
# Ensure destination pack is never proposed on the frontend.
# This should happen only as proposal on `scan_destination`
@@ -391,6 +391,7 @@ def _data_move_line(self, line, **kw):
data["product"]["qty_available"] = product.with_context(
location=line.location_id.id
).qty_available
+ data.update(kw)
return data
def unassign(self, picking_batch_id):
@@ -1576,7 +1577,7 @@ def _schema_for_batch_details(self):
@property
def _schema_for_single_line_details(self):
- schema = self.schemas.move_line(qty_by_packaging=True)
+ schema = self.schemas.move_line()
schema["picking"] = self.schemas._schema_dict_of(self.schemas.picking())
schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch())
return schema
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index f5482d075d..55fbb39b54 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -122,7 +122,7 @@ def test_data_picking(self):
"name": self.picking.name,
"note": "read me",
"origin": "created by test",
- "weight": 155.0,
+ "weight": 110.0,
"partner": {"id": self.customer.id, "name": self.customer.name},
}
self.assertDictEqual(data, expected)
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index 81be39f882..6f3c85d62d 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -171,7 +171,7 @@ def test_data_picking(self):
"name": picking.name,
"note": "read me",
"origin": "created by test",
- "weight": 155.0,
+ "weight": 110.0,
"partner": {"id": self.customer.id, "name": self.customer.name},
"priority": "Very Urgent",
"operation_type": {
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index 57ad3b68cc..2fd44ecb46 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -20,11 +20,11 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="cluster_picking")
- def _line_data(self, move_line, qty=None, package_dest=False, **kw):
+ def _line_data(self, move_line, qty=None, package_dest=False):
picking = move_line.picking_id
# A package exists on the move line, because the quant created
# by ``_simulate_batch_selected`` has a package.
- data = self.data.move_line(move_line, **kw)
+ data = self.data.move_line(move_line)
if not package_dest:
data["package_dest"] = None
if qty:
@@ -65,6 +65,6 @@ def setUpClassBaseData(cls, *args, **kwargs):
[[cls.BatchProduct(product=cls.product_a, quantity=1)]]
)
- def _line_data(self, move_line, qty=1.0, **kw):
+ def _line_data(self, move_line, qty=1.0):
# just force qty to 1.0
- return super()._line_data(move_line, qty=qty, **kw)
+ return super()._line_data(move_line, qty=qty)
diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
index 37a6f6be64..f68772726b 100644
--- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py
+++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
@@ -48,14 +48,14 @@ def _test_change_pack_lot(self, line, barcode, success=True, message=None):
response,
message=message,
next_state="scan_destination",
- data=self._line_data(line, qty_by_packaging=True),
+ data=self._line_data(line),
)
else:
self.assert_response(
response,
message=message,
next_state="change_pack_lot",
- data=self._line_data(line, qty_by_packaging=True),
+ data=self._line_data(line),
)
def _skip_line(self, line, next_line=None):
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index ec6494967b..b67471d560 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -25,9 +25,7 @@ def _scan_line_ok(self, line, scanned):
},
)
self.assert_response(
- response,
- next_state="scan_destination",
- data=self._line_data(line, qty_by_packaging=True),
+ response, next_state="scan_destination", data=self._line_data(line)
)
def _scan_line_error(self, line, scanned, message):
@@ -43,7 +41,7 @@ def _scan_line_error(self, line, scanned, message):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(line, qty_by_packaging=True),
+ data=self._line_data(line),
message=message,
)
@@ -326,7 +324,7 @@ def test_scan_destination_pack_ok(self):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(next_line, qty_by_packaging=True),
+ data=self._line_data(next_line),
message={
"message_type": "success",
"body": "{} {} put in {}".format(
@@ -387,9 +385,7 @@ def test_scan_destination_pack_not_empty_same_picking(self):
response,
next_state="start_line",
# we did not pick this line, so it should go there
- data=self._line_data(
- self.one_line_picking.move_line_ids, qty_by_packaging=True
- ),
+ data=self._line_data(self.one_line_picking.move_line_ids),
message=self.ANY,
)
@@ -413,7 +409,7 @@ def test_scan_destination_pack_not_empty_different_picking(self):
self.assert_response(
response,
next_state="scan_destination",
- data=self._line_data(line, qty_by_packaging=True),
+ data=self._line_data(line),
message={
"message_type": "error",
"body": "The destination bin {} is not empty,"
@@ -437,7 +433,7 @@ def test_scan_destination_pack_bin_not_found(self):
self.assert_response(
response,
next_state="scan_destination",
- data=self._line_data(line, qty_by_packaging=True),
+ data=self._line_data(line),
message={
"message_type": "error",
"body": "Bin {} doesn't exist".format("⌿"),
@@ -459,7 +455,7 @@ def test_scan_destination_pack_quantity_more(self):
self.assert_response(
response,
next_state="scan_destination",
- data=self._line_data(line, qty_by_packaging=True),
+ data=self._line_data(line),
message={
"message_type": "error",
"body": "You must not pick more than {} units.".format(
@@ -497,7 +493,7 @@ def test_scan_destination_pack_quantity_less(self):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(new_line, qty_by_packaging=True),
+ data=self._line_data(new_line),
message={
"message_type": "success",
"body": "{} {} put in {}".format(
@@ -584,7 +580,7 @@ def test_is_zero_is_empty(self):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(self.next_line, qty_by_packaging=True),
+ data=self._line_data(self.next_line),
message={
"message_type": "success",
"body": "{} {} put in {}".format(
@@ -620,7 +616,7 @@ def test_is_zero_is_not_empty(self):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(self.next_line, qty_by_packaging=True),
+ data=self._line_data(self.next_line),
message={
"message_type": "success",
"body": "{} {} put in {}".format(
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index e74b272247..1b86cdf690 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -277,7 +277,7 @@ def test_confirm_start_ok(self):
response = self.service.dispatch(
"confirm_start", params={"picking_batch_id": self.batch.id}
)
- data = self.data.move_line(first_move_line, qty_by_packaging=True)
+ data = self.data.move_line(first_move_line)
data["package_dest"] = None
data["picking"] = self.data.picking(picking)
data["batch"] = self.data.picking_batch(batch)
diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py
index 9ebfb12dac..a0054f741e 100644
--- a/shopfloor/tests/test_cluster_picking_skip.py
+++ b/shopfloor/tests/test_cluster_picking_skip.py
@@ -32,9 +32,7 @@ def _skip_line(self, line, next_line=None):
)
if next_line:
self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(next_line, qty_by_packaging=True),
+ response, next_state="start_line", data=self._line_data(next_line)
)
return response
diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py
index ce08643e3e..8d1eaa0a83 100644
--- a/shopfloor/tests/test_cluster_picking_stock_issue.py
+++ b/shopfloor/tests/test_cluster_picking_stock_issue.py
@@ -41,7 +41,7 @@ def _stock_issue(self, line, next_line_func=None):
self.assert_response(
response,
next_state="start_line",
- data=self._line_data(next_line_func(), qty_by_packaging=True),
+ data=self._line_data(next_line_func()),
)
else:
self.assert_response(
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index b91922591e..6528eafe12 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -213,7 +213,7 @@ def test_set_destination_all_remaining_lines(self):
# the remaining move line still needs to be picked
response,
next_state="start_line",
- data=self._line_data(move_lines[2], qty_by_packaging=True),
+ data=self._line_data(move_lines[2]),
message={"body": "Batch Transfer line done", "message_type": "success"},
)
From 773751693c921120ed1253a56d35e62e16393c9f Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 27 May 2020 12:53:54 +0200
Subject: [PATCH 235/940] Revert "backend: add move line prod qty by packaging"
This reverts commit 2f05235ef352dd5d4df3ad6defbac91e51d2fe33.
---
shopfloor/__manifest__.py | 3 --
shopfloor/actions/data.py | 6 +---
shopfloor/services/schema.py | 52 ++++++++++++++++------------
shopfloor/tests/common.py | 4 ---
shopfloor/tests/test_actions_data.py | 20 +----------
5 files changed, 32 insertions(+), 53 deletions(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 7e0f0bddc7..f9e2462919 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -35,9 +35,6 @@
# TODO: used for picking.carrier_id detail info.
# This must be an optional dep
"delivery",
- # TODO: used for calculating qty by packaging to pick
- # This must be an optional dep
- "stock_packaging_calculator",
],
"data": [
"security/ir.model.access.csv",
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index b16a6538fe..0d987e0da0 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -112,7 +112,7 @@ def lots(self, record, **kw):
def _lot_parser(self):
return ["id", "name", "ref"]
- def move_line(self, record, qty_by_packaging=False, **kw):
+ def move_line(self, record, **kw):
record = record.with_context(location=record.location_id.id)
data = self._jsonify(record, self._move_line_parser)
if data:
@@ -128,10 +128,6 @@ def move_line(self, record, qty_by_packaging=False, **kw):
),
}
)
- if qty_by_packaging:
- data["qty_by_packaging"] = record.product_id.product_qty_by_packaging(
- record.product_uom_qty
- )
return data
def move_lines(self, records, **kw):
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 868821378e..6e97fa3525 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -69,33 +69,41 @@ def picking(self):
},
}
- def move_line(self, with_packaging=False, qty_by_packaging=False):
- schema = {
+ def move_line(self, with_packaging=False):
+ return {
"id": {"type": "integer", "required": True},
"qty_done": {"type": "float", "required": True},
"quantity": {"type": "float", "required": True},
- "product": self._schema_dict_of(self.product(), required=True),
- "lot": self._schema_dict_of(self.lot()),
- "package_src": self._schema_dict_of(
- self.package(with_packaging=with_packaging), required=True
- ),
- "package_dest": self._schema_dict_of(
- self.package(with_packaging=with_packaging)
- ),
- "location_src": self._schema_dict_of(
- self.location(), required=True, nullable=False
- ),
- "location_dest": self._schema_dict_of(
- self.location(), required=True, nullable=False
- ),
- }
- if qty_by_packaging:
- schema["qty_by_packaging"] = {
- "type": "list",
+ "product": {"type": "dict", "required": True, "schema": self.product()},
+ "lot": {
+ "type": "dict",
+ "required": False,
"nullable": True,
+ "schema": self.lot(),
+ },
+ "package_src": {
+ "type": "dict",
+ "required": True,
+ "nullable": True,
+ "schema": self.package(with_packaging=with_packaging),
+ },
+ "package_dest": {
+ "type": "dict",
"required": False,
- }
- return schema
+ "nullable": True,
+ "schema": self.package(with_packaging=with_packaging),
+ },
+ "location_src": {
+ "type": "dict",
+ "required": True,
+ "schema": self.location(),
+ },
+ "location_dest": {
+ "type": "dict",
+ "required": True,
+ "schema": self.location(),
+ },
+ }
def product(self):
return {
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index f508b8b9bc..1f83cde1eb 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -156,7 +156,6 @@ def setUpClassBaseData(cls):
"name": "Box",
"product_id": cls.product_a.id,
"barcode": "ProductABox",
- "qty": 40,
}
)
)
@@ -181,7 +180,6 @@ def setUpClassBaseData(cls):
"name": "Box",
"product_id": cls.product_b.id,
"barcode": "ProductBBox",
- "qty": 30,
}
)
)
@@ -206,7 +204,6 @@ def setUpClassBaseData(cls):
"name": "Box",
"product_id": cls.product_b.id,
"barcode": "ProductCBox",
- "qty": 20,
}
)
)
@@ -231,7 +228,6 @@ def setUpClassBaseData(cls):
"name": "Box",
"product_id": cls.product_d.id,
"barcode": "ProductDBox",
- "qty": 10,
}
)
)
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 55fbb39b54..d0612fe1ee 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -29,7 +29,7 @@ def setUpClassBaseData(cls):
(cls.product_a, 10),
(cls.product_b, 10),
(cls.product_c, 10),
- (cls.product_d, 25),
+ (cls.product_d, 10),
]
)
# put product A in a package
@@ -231,24 +231,6 @@ def test_data_move_line_raw(self):
}
self.assertDictEqual(data, expected)
- def test_data_move_line_with_qty_by_packaging(self):
- move_line = self.move_d.move_line_ids
- data = self.data.move_line(move_line, qty_by_packaging=True)
- self.assert_schema(self.schema.move_line(qty_by_packaging=True), data)
- expected = {
- "id": move_line.id,
- "qty_done": 0.0,
- "quantity": move_line.product_uom_qty,
- "product": self._expected_product(self.product_d),
- "lot": None,
- "package_src": None,
- "package_dest": None,
- "location_src": self._expected_location(move_line.location_id),
- "location_dest": self._expected_location(move_line.location_dest_id),
- "qty_by_packaging": [(2, "Box"), (5, "Units")],
- }
- self.assertDictEqual(data, expected)
-
class ActionsDataCaseBatchPicking(ActionsDataCaseBase, PickingBatchMixin):
@classmethod
From 7108b262033a74aa5ddef20cbc47484cd6009c82 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 28 May 2020 08:34:12 +0200
Subject: [PATCH 236/940] checkout: clean some stuff
* use data_struct and msg_store properties as in other workflows
* put the _response_for_ methods first
---
shopfloor/services/checkout.py | 238 ++++++++++++++++-----------------
1 file changed, 113 insertions(+), 125 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 73cb1395d2..f427115d00 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -34,6 +34,98 @@ class Checkout(Component):
_usage = "checkout"
_description = __doc__
+ @property
+ def data_struct(self):
+ return self.actions_for("data")
+
+ @property
+ def msg_store(self):
+ return self.actions_for("message")
+
+ def _response_for_select_line(self, picking, message=None):
+ if all(line.shopfloor_checkout_done for line in picking.move_line_ids):
+ return self._response_for_summary(picking, message=message)
+ return self._response(
+ next_state="select_line",
+ data={"picking": self._data_for_stock_picking(picking)},
+ message=message,
+ )
+
+ def _response_for_summary(self, picking, need_confirm=False, message=None):
+ return self._response(
+ next_state="summary" if not need_confirm else "confirm_done",
+ data={
+ "picking": self._data_for_stock_picking(picking, done=True),
+ "all_processed": not bool(self._lines_to_pack(picking)),
+ },
+ message=message,
+ )
+
+ def _response_for_select_document(self, message=None):
+ return self._response(next_state="select_document", message=message)
+
+ def _response_for_manual_selection(self, message=None):
+ pickings = self.env["stock.picking"].search(
+ self._domain_for_list_stock_picking(),
+ order=self._order_for_list_stock_picking(),
+ )
+ data = {"pickings": self.data_struct.pickings(pickings)}
+ return self._response(next_state="manual_selection", data=data, message=message)
+
+ def _response_for_select_package(self, lines, message=None):
+ picking = lines.mapped("picking_id")
+ return self._response(
+ next_state="select_package",
+ data={
+ "selected_move_lines": self._data_for_move_lines(lines.sorted()),
+ "picking": self.data_struct.picking(picking),
+ },
+ message=message,
+ )
+
+ def _response_for_select_dest_package(self, picking, move_lines, message=None):
+ packages = picking.mapped("move_line_ids.package_id") | picking.mapped(
+ "move_line_ids.result_package_id"
+ )
+ if not packages:
+ return self._response_for_select_package(
+ move_lines,
+ message={
+ "message_type": "warning",
+ "body": _("No valid package to select."),
+ },
+ )
+ picking_data = self.data_struct.picking(picking)
+ packages_data = self.data_struct.packages(
+ packages.sorted(), picking=picking, with_packaging=True
+ )
+ return self._response(
+ next_state="select_dest_package",
+ data={
+ "picking": picking_data,
+ "packages": packages_data,
+ "selected_move_lines": self._data_for_move_lines(move_lines.sorted()),
+ },
+ message=message,
+ )
+
+ def _response_for_change_packaging(self, picking, package, packaging_list):
+ if not package:
+ return self._response_for_summary(
+ picking, message=self.msg_store.record_not_found()
+ )
+
+ return self._response(
+ next_state="change_packaging",
+ data={
+ "picking": self.data_struct.picking(picking),
+ "package": self.data_struct.package(
+ package, picking=picking, with_packaging=True
+ ),
+ "packagings": self.data_struct.packagings(packaging_list.sorted()),
+ },
+ )
+
def scan_document(self, barcode):
"""Scan a package, a stock.picking or a location
@@ -57,7 +149,6 @@ def scan_document(self, barcode):
destination pack set
"""
search = self.actions_for("search")
- message = self.actions_for("message")
picking = search.picking_from_scan(barcode)
if not picking:
location = search.location_from_scan(barcode)
@@ -66,7 +157,7 @@ def scan_document(self, barcode):
self.picking_types.mapped("default_location_src_id")
):
return self._response_for_select_document(
- message=message.location_not_allowed()
+ message=self.msg_store.location_not_allowed()
)
lines = location.source_move_line_ids
pickings = lines.mapped("picking_id")
@@ -103,62 +194,37 @@ def scan_document(self, barcode):
return self._select_picking(picking, "select_document")
def _select_picking(self, picking, state_for_error):
- message = self.actions_for("message")
if not picking:
if state_for_error == "manual_selection":
return self._response_for_manual_selection(
- message=message.stock_picking_not_found()
+ message=self.msg_store.stock_picking_not_found()
)
return self._response_for_select_document(
- message=message.barcode_not_found()
+ message=self.msg_store.barcode_not_found()
)
if picking.picking_type_id not in self.picking_types:
if state_for_error == "manual_selection":
return self._response_for_manual_selection(
- message=message.cannot_move_something_in_picking_type()
+ message=self.msg_store.cannot_move_something_in_picking_type()
)
return self._response_for_select_document(
- message=message.cannot_move_something_in_picking_type()
+ message=self.msg_store.cannot_move_something_in_picking_type()
)
if picking.state != "assigned":
if state_for_error == "manual_selection":
return self._response_for_manual_selection(
- message=message.stock_picking_not_available(picking)
+ message=self.msg_store.stock_picking_not_available(picking)
)
return self._response_for_select_document(
- message=message.stock_picking_not_available(picking)
+ message=self.msg_store.stock_picking_not_available(picking)
)
return self._response_for_select_line(picking)
- def _response_for_select_line(self, picking, message=None):
- if all(line.shopfloor_checkout_done for line in picking.move_line_ids):
- return self._response_for_summary(picking, message=message)
- return self._response(
- next_state="select_line",
- data={"picking": self._data_for_stock_picking(picking)},
- message=message,
- )
-
- def _response_for_summary(self, picking, need_confirm=False, message=None):
- return self._response(
- next_state="summary" if not need_confirm else "confirm_done",
- data={
- "picking": self._data_for_stock_picking(picking, done=True),
- "all_processed": not bool(self._lines_to_pack(picking)),
- },
- message=message,
- )
-
- def _response_for_select_document(self, message=None):
- return self._response(next_state="select_document", message=message)
-
def _data_for_move_lines(self, lines, **kw):
- data_struct = self.actions_for("data")
- return data_struct.move_lines(lines, **kw)
+ return self.data_struct.move_lines(lines, **kw)
def _data_for_stock_picking(self, picking, done=False):
- data_struct = self.actions_for("data")
- data = data_struct.picking(picking)
+ data = self.data_struct.picking(picking)
line_picker = self._lines_checkout_done if done else self._lines_to_pack
data.update(
{
@@ -195,15 +261,6 @@ def list_stock_picking(self):
"""
return self._response_for_manual_selection()
- def _response_for_manual_selection(self, message=None):
- pickings = self.env["stock.picking"].search(
- self._domain_for_list_stock_picking(),
- order=self._order_for_list_stock_picking(),
- )
- data_struct = self.actions_for("data")
- data = {"pickings": data_struct.pickings(pickings)}
- return self._response(next_state="manual_selection", data=data, message=message)
-
def select(self, picking_id):
"""Select a stock picking for the scenario
@@ -225,18 +282,6 @@ def select(self, picking_id):
picking = self.env["stock.picking"].browse(picking_id).exists()
return self._select_picking(picking, "manual_selection")
- def _response_for_select_package(self, lines, message=None):
- data_struct = self.actions_for("data")
- picking = lines.mapped("picking_id")
- return self._response(
- next_state="select_package",
- data={
- "selected_move_lines": self._data_for_move_lines(lines.sorted()),
- "picking": data_struct.picking(picking),
- },
- message=message,
- )
-
def _select_lines(self, lines):
for line in lines:
if line.shopfloor_checkout_done:
@@ -274,7 +319,6 @@ def scan_line(self, picking_id, barcode):
return self._response_stock_picking_does_not_exist()
search = self.actions_for("search")
- message = self.actions_for("message")
selection_lines = self._lines_to_pack(picking)
if not selection_lines:
@@ -293,7 +337,7 @@ def scan_line(self, picking_id, barcode):
return self._select_lines_from_lot(picking, selection_lines, lot)
return self._response_for_select_line(
- picking, message=message.barcode_not_found()
+ picking, message=self.msg_store.barcode_not_found()
)
def _select_lines_from_package(self, picking, selection_lines, package):
@@ -312,10 +356,9 @@ def _select_lines_from_package(self, picking, selection_lines, package):
return self._response_for_select_package(lines)
def _select_lines_from_product(self, picking, selection_lines, product):
- message = self.actions_for("message")
if product.tracking in ("lot", "serial"):
return self._response_for_select_line(
- picking, message=message.scan_lot_on_product_tracked_by_lot()
+ picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot()
)
lines = selection_lines.filtered(lambda l: l.product_id == product)
@@ -339,7 +382,7 @@ def _select_lines_from_product(self, picking, selection_lines, product):
# package.
if packages and len({l.package_id for l in lines}) > 1:
return self._response_for_select_line(
- picking, message=message.product_multiple_packages_scan_package()
+ picking, message=self.msg_store.product_multiple_packages_scan_package()
)
elif packages:
# Select all the lines of the package when we scan a product in a
@@ -360,7 +403,6 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
},
)
- message = self.actions_for("message")
# When lots are as units outside of packages, we can select them for
# packing, but if they are in a package, we want the user to scan the packages.
# If the product is only in one package though, scanning the lot selects
@@ -372,7 +414,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
# package.
if packages and len({l.package_id for l in lines}) > 1:
return self._response_for_select_line(
- picking, message=message.lot_multiple_packages_scan_package()
+ picking, message=self.msg_store.lot_multiple_packages_scan_package()
)
elif packages:
# Select all the lines of the package when we scan a lot in a
@@ -384,17 +426,15 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
def _select_line_package(self, picking, selection_lines, package):
if not package:
- message = self.actions_for("message")
return self._response_for_select_line(
- picking, message=message.record_not_found()
+ picking, message=self.msg_store.record_not_found()
)
return self._select_lines_from_package(picking, selection_lines, package)
def _select_line_move_line(self, picking, selection_lines, move_line):
if not move_line:
- message = self.actions_for("message")
return self._response_for_select_line(
- picking, message=message.record_not_found()
+ picking, message=self.msg_store.record_not_found()
)
# normally, the client should sent only move lines out of packages, but
# in case there is a package, handle it as a package
@@ -445,13 +485,11 @@ def _change_line_qty(
if not picking.exists():
return self._response_stock_picking_does_not_exist()
- message_directory = self.actions_for("message")
-
move_lines = self.env["stock.move.line"].browse(move_line_ids).exists()
message = None
if not move_lines:
- message = message_directory.record_not_found()
+ message = self.msg_store.record_not_found()
for move_line in move_lines:
qty_done = quantity_func(move_line)
if qty_done > move_line.product_uom_qty:
@@ -641,7 +679,6 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
if not picking.exists():
return self._response_stock_picking_does_not_exist()
search = self.actions_for("search")
- message = self.actions_for("message")
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
@@ -649,7 +686,8 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
if product:
if product.tracking in ("lot", "serial"):
return self._response_for_select_package(
- selected_lines, message=message.scan_lot_on_product_tracked_by_lot()
+ selected_lines,
+ message=self.msg_store.scan_lot_on_product_tracked_by_lot(),
)
product_lines = selected_lines.filtered(lambda l: l.product_id == product)
return self._switch_line_qty_done(picking, selected_lines, product_lines)
@@ -670,7 +708,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
)
return self._response_for_select_package(
- selected_lines, message=message.barcode_not_found()
+ selected_lines, message=self.msg_store.barcode_not_found()
)
def new_package(self, picking_id, selected_line_ids):
@@ -734,34 +772,6 @@ def list_dest_package(self, picking_id, selected_line_ids):
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
return self._response_for_select_dest_package(picking, lines)
- def _response_for_select_dest_package(self, picking, move_lines, message=None):
- packages = picking.mapped("move_line_ids.package_id") | picking.mapped(
- "move_line_ids.result_package_id"
- )
- if not packages:
- return self._response_for_select_package(
- move_lines,
- message={
- "message_type": "warning",
- "body": _("No valid package to select."),
- },
- )
- data_struct = self.actions_for("data")
- picking_data = data_struct.picking(picking)
- packages_data = data_struct.packages(
- packages.sorted(), picking=picking, with_packaging=True
- )
- data_struct = self.actions_for("data")
- return self._response(
- next_state="select_dest_package",
- data={
- "picking": picking_data,
- "packages": packages_data,
- "selected_move_lines": self._data_for_move_lines(move_lines.sorted()),
- },
- message=message,
- )
-
def _set_dest_package_from_selection(self, picking, selected_lines, package):
if not package:
return self._response_for_select_dest_package(picking, selected_lines)
@@ -852,25 +862,6 @@ def list_packaging(self, picking_id, package_id):
packaging_list = self._get_allowed_packaging()
return self._response_for_change_packaging(picking, package, packaging_list)
- def _response_for_change_packaging(self, picking, package, packaging_list):
- message = self.actions_for("message")
- data_struct = self.actions_for("data")
- if not package:
- return self._response_for_summary(
- picking, message=message.record_not_found()
- )
-
- return self._response(
- next_state="change_packaging",
- data={
- "picking": data_struct.picking(picking),
- "package": data_struct.package(
- package, picking=picking, with_packaging=True
- ),
- "packagings": data_struct.packagings(packaging_list.sorted()),
- },
- )
-
def set_packaging(self, picking_id, package_id, packaging_id):
"""Set a package type on a package
@@ -878,8 +869,6 @@ def set_packaging(self, picking_id, package_id, packaging_id):
* change_packaging: in case of error
* summary
"""
- message = self.actions_for("message")
-
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
return self._response_stock_picking_does_not_exist()
@@ -888,7 +877,7 @@ def set_packaging(self, picking_id, package_id, packaging_id):
packaging = self.env["product.packaging"].browse(packaging_id).exists()
if not (package and packaging):
return self._response_for_summary(
- picking, message=message.record_not_found()
+ picking, message=self.msg_store.record_not_found()
)
package.product_packaging_id = packaging
return self._response_for_summary(
@@ -915,7 +904,6 @@ def cancel_line(self, picking_id, package_id=None, line_id=None):
Transitions:
* summary
"""
- message = self.actions_for("message")
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
return self._response_stock_picking_does_not_exist()
@@ -924,7 +912,7 @@ def cancel_line(self, picking_id, package_id=None, line_id=None):
line = self.env["stock.move.line"].browse(line_id).exists()
if not package and not line:
return self._response_for_summary(
- picking, message=message.record_not_found()
+ picking, message=self.msg_store.record_not_found()
)
if package:
From 7f271074709be665398bc439df07a0f12d6c78d1 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 29 May 2020 08:53:59 +0200
Subject: [PATCH 237/940] cluster picking: fix reservation inconsistency
When we pick a good and pick less than the reserved quantity,
the endpoint splits the move line in 2 parts: one with the qty_done
set to the quantity passed to the endpoint, another move line for
the remaining.
When the copy of the move line is created, the reserved quantity
on the move line doesn't change, but when the quantity is reduced on
the current line, the reserved quantity on the quant is automatically
reduced accordingly.
We don't want this, because we didn't reduced the total reserved
quantity, only moved it to another move line.
Before:
-------
* We have a quant of 40 product A in Shelf 1
* We reserve a move line of 20 product A in Shelf 1, the reserved
quantity of the quant is 20
* We start the batch transfer, using the barcode scanner app, we
scan the product and update the quantity to pick 13 products only
* A new line of 7 is created, quant's reserved quantity is still 20
* Quantity of the current move line is set to 13, the reserved quantity
of the quant is now 13
Result: we have a desynchronization between the move lines (20 reserved)
and the quants (13 reserved). If we try to unreserve a move line, we
have an error: "It is not possible to unreserve more products of %s than
you have in stock."
After:
------
Same steps as before, but we ignore the update of the quant at the last
step.
Result: both the move lines and quants have 20 units reserved.
---
shopfloor/services/cluster_picking.py | 7 ++++-
shopfloor/tests/test_cluster_picking_scan.py | 29 ++++++++++++++------
2 files changed, 26 insertions(+), 10 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 881b56931e..e40d981491 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -601,7 +601,12 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit
# contained less items than expected)
remaining = move_line.product_uom_qty - quantity
new_line = move_line.copy({"product_uom_qty": remaining, "qty_done": 0})
- move_line.product_uom_qty = quantity
+ # if we didn't bypass reservation update, the quant reservation
+ # would be reduced as much as the deduced quantity, which is wrong
+ # as we only moved the quantity to a new move line
+ move_line.with_context(
+ bypass_reservation_update=True
+ ).product_uom_qty = quantity
search = self.actions_for("search")
bin_package = search.package_from_scan(barcode)
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index b67471d560..11909cccf0 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -467,6 +467,15 @@ def test_scan_destination_pack_quantity_more(self):
def test_scan_destination_pack_quantity_less(self):
"""Pick less units than expected"""
line = self.one_line_picking.move_line_ids
+ quant = self.env["stock.quant"].search(
+ [
+ ("location_id", "=", line.location_id.id),
+ ("product_id", "=", line.product_id.id),
+ ]
+ )
+ quant.ensure_one()
+ self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}])
+
# when we pick less quantity than expected, the line is split
# and the user is proposed to pick the next line for the remaining
# quantity
@@ -479,16 +488,7 @@ def test_scan_destination_pack_quantity_less(self):
"quantity": line.product_uom_qty - 3,
},
)
-
- self.assertRecordValues(
- line,
- [{"qty_done": 7, "result_package_id": self.bin1.id, "product_uom_qty": 7}],
- )
new_line = self.one_line_picking.move_line_ids - line
- self.assertRecordValues(
- new_line,
- [{"qty_done": 0, "result_package_id": False, "product_uom_qty": 3}],
- )
self.assert_response(
response,
@@ -502,6 +502,17 @@ def test_scan_destination_pack_quantity_less(self):
},
)
+ self.assertRecordValues(
+ line,
+ [{"qty_done": 7, "result_package_id": self.bin1.id, "product_uom_qty": 7}],
+ )
+ self.assertRecordValues(
+ new_line,
+ [{"qty_done": 0, "result_package_id": False, "product_uom_qty": 3}],
+ )
+ # the reserved quantity on the quant must stay the same
+ self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}])
+
def test_scan_destination_pack_zero_check(self):
"""Location will be emptied, have to go to zero check"""
line = self.one_line_picking.move_line_ids
From 411acc9bf7d3fb50b7fbb179a5796240bb1f6157 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 28 May 2020 16:12:41 +0200
Subject: [PATCH 238/940] Log shopfloor requests in a table
---
shopfloor/__manifest__.py | 3 +
shopfloor/data/ir_config_parameter_data.xml | 7 ++
shopfloor/data/ir_cron_data.xml | 15 +++
shopfloor/models/__init__.py | 1 +
shopfloor/models/shopfloor_log.py | 58 +++++++++
shopfloor/readme/CONFIGURE.rst | 9 ++
shopfloor/security/ir.model.access.csv | 5 +-
shopfloor/services/service.py | 48 +++++++-
shopfloor/views/menus.xml | 7 ++
shopfloor/views/shopfloor_log_views.xml | 125 ++++++++++++++++++++
10 files changed, 275 insertions(+), 3 deletions(-)
create mode 100644 shopfloor/data/ir_config_parameter_data.xml
create mode 100644 shopfloor/data/ir_cron_data.xml
create mode 100644 shopfloor/models/shopfloor_log.py
create mode 100644 shopfloor/views/shopfloor_log_views.xml
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index f9e2462919..2d0d521699 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -37,12 +37,15 @@
"delivery",
],
"data": [
+ "data/ir_config_parameter_data.xml",
+ "data/ir_cron_data.xml",
"security/ir.model.access.csv",
"views/shopfloor_menu.xml",
"views/stock_picking_type.xml",
"views/stock_location.xml",
"views/stock_move_line.xml",
"views/shopfloor_profile_views.xml",
+ "views/shopfloor_log_views.xml",
"views/menus.xml",
],
"demo": [
diff --git a/shopfloor/data/ir_config_parameter_data.xml b/shopfloor/data/ir_config_parameter_data.xml
new file mode 100644
index 0000000000..2127ea5cbe
--- /dev/null
+++ b/shopfloor/data/ir_config_parameter_data.xml
@@ -0,0 +1,7 @@
+
+
+
+ shopfloor.log.retention.days
+ 30
+
+
diff --git a/shopfloor/data/ir_cron_data.xml b/shopfloor/data/ir_cron_data.xml
new file mode 100644
index 0000000000..865d533988
--- /dev/null
+++ b/shopfloor/data/ir_cron_data.xml
@@ -0,0 +1,15 @@
+
+
+
+ AutoVacuum Shopfloor Logs
+
+
+
+ 1
+ days
+ -1
+
+ code
+ model.autovacuum()
+
+
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index 7f9b986965..1bfd3ece74 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -1,4 +1,5 @@
from . import shopfloor_menu
+from . import shopfloor_log
from . import stock_picking_type
from . import shopfloor_profile
from . import stock_inventory
diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py
new file mode 100644
index 0000000000..a17e9a1432
--- /dev/null
+++ b/shopfloor/models/shopfloor_log.py
@@ -0,0 +1,58 @@
+import logging
+from datetime import datetime, timedelta
+
+from odoo import fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class ShopfloorLog(models.Model):
+ _name = "shopfloor.log"
+ _description = "Shopfloor Logging"
+ _order = "id desc"
+
+ DEFAULT_RETENTION = 30 # days
+
+ request_url = fields.Char(readonly=True, string="Request URL")
+ request_method = fields.Char(readonly=True)
+ params = fields.Text(readonly=True)
+ headers = fields.Text(readonly=True)
+ result = fields.Text(readonly=True)
+ error = fields.Text(readonly=True)
+ state = fields.Selection(
+ selection=[("success", "Success"), ("failed", "Failed")], readonly=True,
+ )
+
+ def _logs_retention_days(self):
+ retention = self.DEFAULT_RETENTION
+ param = (
+ self.env["ir.config_parameter"]
+ .sudo()
+ .get_param("shopfloor.log.retention.days")
+ )
+ if param:
+ try:
+ retention = int(param)
+ except ValueError:
+ _logger.exception(
+ "Could not convert System Parameter"
+ " 'shopfloor.log.retention.days' to integer,"
+ " reverting to the"
+ " default configuration."
+ )
+ return retention
+
+ def logging_active(self):
+ retention = self._logs_retention_days()
+ return retention > 0
+
+ def autovacuum(self):
+ """Delete logs which have exceeded their retention duration
+
+ Called from a cron.
+ """
+ deadline = datetime.now() - timedelta(days=self._logs_retention_days())
+ logs = self.search([("create_date", "<=", deadline)])
+ if logs:
+ logs.unlink()
+ return True
diff --git a/shopfloor/readme/CONFIGURE.rst b/shopfloor/readme/CONFIGURE.rst
index 8442c179fd..84321ab431 100644
--- a/shopfloor/readme/CONFIGURE.rst
+++ b/shopfloor/readme/CONFIGURE.rst
@@ -1 +1,10 @@
writeme
+
+Logs retention
+--------------
+
+By default, Shopfloor logs are kept 30 days.
+You can change the duration of the retention by changing
+the System Parameter ``shopfloor.log.retention.days``.
+
+If the value is set to 0, the logs are not stored at all.
diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv
index 6aca8c733c..bdf728b8e5 100644
--- a/shopfloor/security/ir.model.access.csv
+++ b/shopfloor/security/ir.model.access.csv
@@ -1,5 +1,6 @@
"id","name","model_id/id","group_id/id","perm_read","perm_write","perm_create","perm_unlink"
-"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu",,1,0,0,0
+"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu","stock.group_stock_user",1,0,0,0
"access_shopfloor_menu_stock_manager","shopfloor menu inventory manager","model_shopfloor_menu","stock.group_stock_manager",1,1,1,1
-"access_shopfloor_profile_users","shopfloor profile","model_shopfloor_profile",,1,0,0,0
+"access_shopfloor_profile_users","shopfloor profile","model_shopfloor_profile","stock.group_stock_user",1,0,0,0
"access_shopfloor_profile_stock_manager","shopfloor profile inventory manager","model_shopfloor_profile","stock.group_stock_manager",1,1,1,1
+"access_shopfloor_log","access_shopfloor_log","model_shopfloor_log","stock.group_stock_manager",1,0,0,0
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 3841079f56..fea0a43b69 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -1,5 +1,6 @@
-from odoo import _, exceptions
+from odoo import _, exceptions, registry
from odoo.exceptions import MissingError
+from odoo.http import request
from odoo.osv import expression
from odoo.addons.base_rest.controllers.main import _PseudoCollection
@@ -23,6 +24,51 @@ class BaseShopfloorService(AbstractComponent):
_actions_collection_name = "shopfloor.action"
_expose_model = None
+ def dispatch(self, method_name, _id=None, params=None):
+ try:
+ result = super().dispatch(method_name, _id=_id, params=params)
+ except Exception as err:
+ self.env.cr.rollback()
+ with registry(self.env.cr.dbname).cursor() as cr:
+ env = self.env(cr=cr)
+ self._log_call_in_db(env, _id, params, error=err)
+ raise
+ self._log_call_in_db(self.env, _id, params, result=result)
+ return result
+
+ @property
+ def _log_call_header_strip(self):
+ return ("Cookie", "Api-Key")
+
+ def _log_call_in_db_values(self, _id, params, result=None, error=None):
+ if not request:
+ # In tests, we have no http request
+ return
+ httprequest = request.httprequest
+ headers = dict(httprequest.headers)
+ for header_key in self._log_call_header_strip:
+ if header_key in headers:
+ headers[header_key] = ""
+ if _id:
+ params = dict(params, _id=_id)
+ return {
+ "request_url": httprequest.url,
+ "request_method": httprequest.method,
+ "params": params,
+ "headers": headers,
+ "result": result,
+ "error": error,
+ "state": "success" if result else "failed",
+ }
+
+ def _log_call_in_db(self, env, _id, params, result=None, error=None):
+ if not env["shopfloor.log"].logging_active():
+ return
+ values = self._log_call_in_db_values(_id, params, result=result, error=error)
+ if not values:
+ return
+ env["shopfloor.log"].sudo().create(values)
+
def _get(self, _id):
domain = expression.normalize_domain(self._get_base_search_domain())
domain = expression.AND([domain, [("id", "=", _id)]])
diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml
index 29876a3a77..f1a619ed6b 100644
--- a/shopfloor/views/menus.xml
+++ b/shopfloor/views/menus.xml
@@ -18,4 +18,11 @@
parent="menu_shopfloor_settings"
sequence="20"
/>
+
diff --git a/shopfloor/views/shopfloor_log_views.xml b/shopfloor/views/shopfloor_log_views.xml
new file mode 100644
index 0000000000..6ebb576f7c
--- /dev/null
+++ b/shopfloor/views/shopfloor_log_views.xml
@@ -0,0 +1,125 @@
+
+
+
+ shopfloor.log tree
+ shopfloor.log
+
+
+
+
+
+
+
+
+
+
+
+ shopfloor.log form
+ shopfloor.log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ shopfloor.log search
+ shopfloor.log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Shopfloor Logs
+ shopfloor.log
+ ir.actions.act_window
+ tree,form
+
+
From 295d14143eb5516bc3e267902d44850630aedab7 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 29 May 2020 10:56:00 +0200
Subject: [PATCH 239/940] backend: fix packaging handling
---
shopfloor/actions/data.py | 20 +-
shopfloor/services/checkout.py | 238 ++++++++++----------
shopfloor/services/schema.py | 9 +-
shopfloor/tests/test_actions_data.py | 15 +-
shopfloor/tests/test_actions_data_detail.py | 3 +-
5 files changed, 155 insertions(+), 130 deletions(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 0d987e0da0..9d53aa23bf 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -18,6 +18,9 @@ def _jsonify(self, recordset, parser, multi=False, **kw):
return res[0] if res else None
return res
+ def _simple_record_parser(self):
+ return ["id", "name"]
+
def partner(self, record, **kw):
return self._jsonify(record, self._partner_parser, **kw)
@@ -26,7 +29,7 @@ def partners(self, record, **kw):
@property
def _partner_parser(self):
- return ["id", "name"]
+ return self._simple_record_parser()
def location(self, record, **kw):
return self._jsonify(
@@ -95,12 +98,12 @@ def _package_packaging_parser(self):
def packaging(self, record, **kw):
return self._jsonify(record, self._packaging_parser, **kw)
- def packagings(self, record, **kw):
+ def packaging_list(self, record, **kw):
return self.packaging(record, multi=True)
@property
def _packaging_parser(self):
- return ["id", "name"]
+ return self._simple_record_parser() + ["qty"]
def lot(self, record, **kw):
return self._jsonify(record, self._lot_parser, **kw)
@@ -110,7 +113,7 @@ def lots(self, record, **kw):
@property
def _lot_parser(self):
- return ["id", "name", "ref"]
+ return self._simple_record_parser() + ["ref"]
def move_line(self, record, **kw):
record = record.with_context(location=record.location_id.id)
@@ -153,7 +156,14 @@ def products(self, record, **kw):
@property
def _product_parser(self):
- return ["id", "name", "display_name", "default_code", "barcode"]
+ return [
+ "id",
+ "name",
+ "display_name",
+ "default_code",
+ "barcode",
+ ("packaging_ids:packaging", self._packaging_parser),
+ ]
def picking_batch(self, record, with_pickings=False, **kw):
parser = self._picking_batch_parser
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index f427115d00..fab2f37847 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -34,98 +34,6 @@ class Checkout(Component):
_usage = "checkout"
_description = __doc__
- @property
- def data_struct(self):
- return self.actions_for("data")
-
- @property
- def msg_store(self):
- return self.actions_for("message")
-
- def _response_for_select_line(self, picking, message=None):
- if all(line.shopfloor_checkout_done for line in picking.move_line_ids):
- return self._response_for_summary(picking, message=message)
- return self._response(
- next_state="select_line",
- data={"picking": self._data_for_stock_picking(picking)},
- message=message,
- )
-
- def _response_for_summary(self, picking, need_confirm=False, message=None):
- return self._response(
- next_state="summary" if not need_confirm else "confirm_done",
- data={
- "picking": self._data_for_stock_picking(picking, done=True),
- "all_processed": not bool(self._lines_to_pack(picking)),
- },
- message=message,
- )
-
- def _response_for_select_document(self, message=None):
- return self._response(next_state="select_document", message=message)
-
- def _response_for_manual_selection(self, message=None):
- pickings = self.env["stock.picking"].search(
- self._domain_for_list_stock_picking(),
- order=self._order_for_list_stock_picking(),
- )
- data = {"pickings": self.data_struct.pickings(pickings)}
- return self._response(next_state="manual_selection", data=data, message=message)
-
- def _response_for_select_package(self, lines, message=None):
- picking = lines.mapped("picking_id")
- return self._response(
- next_state="select_package",
- data={
- "selected_move_lines": self._data_for_move_lines(lines.sorted()),
- "picking": self.data_struct.picking(picking),
- },
- message=message,
- )
-
- def _response_for_select_dest_package(self, picking, move_lines, message=None):
- packages = picking.mapped("move_line_ids.package_id") | picking.mapped(
- "move_line_ids.result_package_id"
- )
- if not packages:
- return self._response_for_select_package(
- move_lines,
- message={
- "message_type": "warning",
- "body": _("No valid package to select."),
- },
- )
- picking_data = self.data_struct.picking(picking)
- packages_data = self.data_struct.packages(
- packages.sorted(), picking=picking, with_packaging=True
- )
- return self._response(
- next_state="select_dest_package",
- data={
- "picking": picking_data,
- "packages": packages_data,
- "selected_move_lines": self._data_for_move_lines(move_lines.sorted()),
- },
- message=message,
- )
-
- def _response_for_change_packaging(self, picking, package, packaging_list):
- if not package:
- return self._response_for_summary(
- picking, message=self.msg_store.record_not_found()
- )
-
- return self._response(
- next_state="change_packaging",
- data={
- "picking": self.data_struct.picking(picking),
- "package": self.data_struct.package(
- package, picking=picking, with_packaging=True
- ),
- "packagings": self.data_struct.packagings(packaging_list.sorted()),
- },
- )
-
def scan_document(self, barcode):
"""Scan a package, a stock.picking or a location
@@ -149,6 +57,7 @@ def scan_document(self, barcode):
destination pack set
"""
search = self.actions_for("search")
+ message = self.actions_for("message")
picking = search.picking_from_scan(barcode)
if not picking:
location = search.location_from_scan(barcode)
@@ -157,7 +66,7 @@ def scan_document(self, barcode):
self.picking_types.mapped("default_location_src_id")
):
return self._response_for_select_document(
- message=self.msg_store.location_not_allowed()
+ message=message.location_not_allowed()
)
lines = location.source_move_line_ids
pickings = lines.mapped("picking_id")
@@ -194,37 +103,62 @@ def scan_document(self, barcode):
return self._select_picking(picking, "select_document")
def _select_picking(self, picking, state_for_error):
+ message = self.actions_for("message")
if not picking:
if state_for_error == "manual_selection":
return self._response_for_manual_selection(
- message=self.msg_store.stock_picking_not_found()
+ message=message.stock_picking_not_found()
)
return self._response_for_select_document(
- message=self.msg_store.barcode_not_found()
+ message=message.barcode_not_found()
)
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()
+ message=message.cannot_move_something_in_picking_type()
)
return self._response_for_select_document(
- message=self.msg_store.cannot_move_something_in_picking_type()
+ message=message.cannot_move_something_in_picking_type()
)
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)
+ message=message.stock_picking_not_available(picking)
)
return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_available(picking)
+ message=message.stock_picking_not_available(picking)
)
return self._response_for_select_line(picking)
+ def _response_for_select_line(self, picking, message=None):
+ if all(line.shopfloor_checkout_done for line in picking.move_line_ids):
+ return self._response_for_summary(picking, message=message)
+ return self._response(
+ next_state="select_line",
+ data={"picking": self._data_for_stock_picking(picking)},
+ message=message,
+ )
+
+ def _response_for_summary(self, picking, need_confirm=False, message=None):
+ return self._response(
+ next_state="summary" if not need_confirm else "confirm_done",
+ data={
+ "picking": self._data_for_stock_picking(picking, done=True),
+ "all_processed": not bool(self._lines_to_pack(picking)),
+ },
+ message=message,
+ )
+
+ def _response_for_select_document(self, message=None):
+ return self._response(next_state="select_document", message=message)
+
def _data_for_move_lines(self, lines, **kw):
- return self.data_struct.move_lines(lines, **kw)
+ data_struct = self.actions_for("data")
+ return data_struct.move_lines(lines, **kw)
def _data_for_stock_picking(self, picking, done=False):
- data = self.data_struct.picking(picking)
+ data_struct = self.actions_for("data")
+ data = data_struct.picking(picking)
line_picker = self._lines_checkout_done if done else self._lines_to_pack
data.update(
{
@@ -261,6 +195,15 @@ def list_stock_picking(self):
"""
return self._response_for_manual_selection()
+ def _response_for_manual_selection(self, message=None):
+ pickings = self.env["stock.picking"].search(
+ self._domain_for_list_stock_picking(),
+ order=self._order_for_list_stock_picking(),
+ )
+ data_struct = self.actions_for("data")
+ data = {"pickings": data_struct.pickings(pickings)}
+ return self._response(next_state="manual_selection", data=data, message=message)
+
def select(self, picking_id):
"""Select a stock picking for the scenario
@@ -282,6 +225,18 @@ def select(self, picking_id):
picking = self.env["stock.picking"].browse(picking_id).exists()
return self._select_picking(picking, "manual_selection")
+ def _response_for_select_package(self, lines, message=None):
+ data_struct = self.actions_for("data")
+ picking = lines.mapped("picking_id")
+ return self._response(
+ next_state="select_package",
+ data={
+ "selected_move_lines": self._data_for_move_lines(lines.sorted()),
+ "picking": data_struct.picking(picking),
+ },
+ message=message,
+ )
+
def _select_lines(self, lines):
for line in lines:
if line.shopfloor_checkout_done:
@@ -319,6 +274,7 @@ def scan_line(self, picking_id, barcode):
return self._response_stock_picking_does_not_exist()
search = self.actions_for("search")
+ message = self.actions_for("message")
selection_lines = self._lines_to_pack(picking)
if not selection_lines:
@@ -337,7 +293,7 @@ def scan_line(self, picking_id, barcode):
return self._select_lines_from_lot(picking, selection_lines, lot)
return self._response_for_select_line(
- picking, message=self.msg_store.barcode_not_found()
+ picking, message=message.barcode_not_found()
)
def _select_lines_from_package(self, picking, selection_lines, package):
@@ -356,9 +312,10 @@ def _select_lines_from_package(self, picking, selection_lines, package):
return self._response_for_select_package(lines)
def _select_lines_from_product(self, picking, selection_lines, product):
+ message = self.actions_for("message")
if product.tracking in ("lot", "serial"):
return self._response_for_select_line(
- picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot()
+ picking, message=message.scan_lot_on_product_tracked_by_lot()
)
lines = selection_lines.filtered(lambda l: l.product_id == product)
@@ -382,7 +339,7 @@ def _select_lines_from_product(self, picking, selection_lines, product):
# package.
if packages and len({l.package_id for l in lines}) > 1:
return self._response_for_select_line(
- picking, message=self.msg_store.product_multiple_packages_scan_package()
+ picking, message=message.product_multiple_packages_scan_package()
)
elif packages:
# Select all the lines of the package when we scan a product in a
@@ -403,6 +360,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
},
)
+ message = self.actions_for("message")
# When lots are as units outside of packages, we can select them for
# packing, but if they are in a package, we want the user to scan the packages.
# If the product is only in one package though, scanning the lot selects
@@ -414,7 +372,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
# package.
if packages and len({l.package_id for l in lines}) > 1:
return self._response_for_select_line(
- picking, message=self.msg_store.lot_multiple_packages_scan_package()
+ picking, message=message.lot_multiple_packages_scan_package()
)
elif packages:
# Select all the lines of the package when we scan a lot in a
@@ -426,15 +384,17 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
def _select_line_package(self, picking, selection_lines, package):
if not package:
+ message = self.actions_for("message")
return self._response_for_select_line(
- picking, message=self.msg_store.record_not_found()
+ picking, message=message.record_not_found()
)
return self._select_lines_from_package(picking, selection_lines, package)
def _select_line_move_line(self, picking, selection_lines, move_line):
if not move_line:
+ message = self.actions_for("message")
return self._response_for_select_line(
- picking, message=self.msg_store.record_not_found()
+ picking, message=message.record_not_found()
)
# normally, the client should sent only move lines out of packages, but
# in case there is a package, handle it as a package
@@ -485,11 +445,13 @@ def _change_line_qty(
if not picking.exists():
return self._response_stock_picking_does_not_exist()
+ message_directory = self.actions_for("message")
+
move_lines = self.env["stock.move.line"].browse(move_line_ids).exists()
message = None
if not move_lines:
- message = self.msg_store.record_not_found()
+ message = message_directory.record_not_found()
for move_line in move_lines:
qty_done = quantity_func(move_line)
if qty_done > move_line.product_uom_qty:
@@ -679,6 +641,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
if not picking.exists():
return self._response_stock_picking_does_not_exist()
search = self.actions_for("search")
+ message = self.actions_for("message")
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
@@ -686,8 +649,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
if product:
if product.tracking in ("lot", "serial"):
return self._response_for_select_package(
- selected_lines,
- message=self.msg_store.scan_lot_on_product_tracked_by_lot(),
+ selected_lines, message=message.scan_lot_on_product_tracked_by_lot()
)
product_lines = selected_lines.filtered(lambda l: l.product_id == product)
return self._switch_line_qty_done(picking, selected_lines, product_lines)
@@ -708,7 +670,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
)
return self._response_for_select_package(
- selected_lines, message=self.msg_store.barcode_not_found()
+ selected_lines, message=message.barcode_not_found()
)
def new_package(self, picking_id, selected_line_ids):
@@ -772,6 +734,34 @@ def list_dest_package(self, picking_id, selected_line_ids):
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
return self._response_for_select_dest_package(picking, lines)
+ def _response_for_select_dest_package(self, picking, move_lines, message=None):
+ packages = picking.mapped("move_line_ids.package_id") | picking.mapped(
+ "move_line_ids.result_package_id"
+ )
+ if not packages:
+ return self._response_for_select_package(
+ move_lines,
+ message={
+ "message_type": "warning",
+ "body": _("No valid package to select."),
+ },
+ )
+ data_struct = self.actions_for("data")
+ picking_data = data_struct.picking(picking)
+ packages_data = data_struct.packages(
+ packages.sorted(), picking=picking, with_packaging=True
+ )
+ data_struct = self.actions_for("data")
+ return self._response(
+ next_state="select_dest_package",
+ data={
+ "picking": picking_data,
+ "packages": packages_data,
+ "selected_move_lines": self._data_for_move_lines(move_lines.sorted()),
+ },
+ message=message,
+ )
+
def _set_dest_package_from_selection(self, picking, selected_lines, package):
if not package:
return self._response_for_select_dest_package(picking, selected_lines)
@@ -862,6 +852,25 @@ def list_packaging(self, picking_id, package_id):
packaging_list = self._get_allowed_packaging()
return self._response_for_change_packaging(picking, package, packaging_list)
+ def _response_for_change_packaging(self, picking, package, packaging_list):
+ message = self.actions_for("message")
+ data_struct = self.actions_for("data")
+ if not package:
+ return self._response_for_summary(
+ picking, message=message.record_not_found()
+ )
+
+ return self._response(
+ next_state="change_packaging",
+ data={
+ "picking": data_struct.picking(picking),
+ "package": data_struct.package(
+ package, picking=picking, with_packaging=True
+ ),
+ "packagings": data_struct.packaging_list(packaging_list.sorted()),
+ },
+ )
+
def set_packaging(self, picking_id, package_id, packaging_id):
"""Set a package type on a package
@@ -869,6 +878,8 @@ def set_packaging(self, picking_id, package_id, packaging_id):
* change_packaging: in case of error
* summary
"""
+ message = self.actions_for("message")
+
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
return self._response_stock_picking_does_not_exist()
@@ -877,7 +888,7 @@ def set_packaging(self, picking_id, package_id, packaging_id):
packaging = self.env["product.packaging"].browse(packaging_id).exists()
if not (package and packaging):
return self._response_for_summary(
- picking, message=self.msg_store.record_not_found()
+ picking, message=message.record_not_found()
)
package.product_packaging_id = packaging
return self._response_for_summary(
@@ -904,6 +915,7 @@ def cancel_line(self, picking_id, package_id=None, line_id=None):
Transitions:
* summary
"""
+ message = self.actions_for("message")
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
return self._response_stock_picking_does_not_exist()
@@ -912,7 +924,7 @@ def cancel_line(self, picking_id, package_id=None, line_id=None):
line = self.env["stock.move.line"].browse(line_id).exists()
if not package and not line:
return self._response_for_summary(
- picking, message=self.msg_store.record_not_found()
+ picking, message=message.record_not_found()
)
if package:
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 6e97fa3525..ebe1523a29 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -112,6 +112,7 @@ def product(self):
"display_name": {"type": "string", "nullable": False, "required": True},
"default_code": {"type": "string", "nullable": False, "required": True},
"barcode": {"type": "string", "nullable": True, "required": False},
+ "packaging": self._schema_list_of(self.packaging()),
}
def package(self, with_packaging=False):
@@ -122,12 +123,7 @@ def package(self, with_packaging=False):
"move_line_count": {"required": False, "nullable": True, "type": "integer"},
}
if with_packaging:
- schema["packaging"] = {
- "type": "dict",
- "required": True,
- "nullable": True,
- "schema": self.packaging(),
- }
+ schema["packaging"] = self._schema_dict_of(self.packaging())
return schema
def lot(self):
@@ -148,6 +144,7 @@ def packaging(self):
return {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
+ "qty": {"type": "float", "required": True},
}
def picking_batch(self, with_pickings=False):
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index d0612fe1ee..395de60b80 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -64,6 +64,14 @@ def _expected_product(self, record, **kw):
"display_name": record.display_name,
"default_code": record.default_code,
"barcode": record.barcode,
+ "packaging": [self._expected_packaging(x) for x in record.packaging_ids],
+ }
+
+ def _expected_packaging(self, record, **kw):
+ return {
+ "id": record.id,
+ "name": record.name,
+ "qty": record.qty,
}
@@ -71,8 +79,7 @@ class ActionsDataCase(ActionsDataCaseBase):
def test_data_packaging(self):
data = self.data.packaging(self.packaging)
self.assert_schema(self.schema.packaging(), data)
- expected = {"id": self.packaging.id, "name": self.packaging.name}
- self.assertDictEqual(data, expected)
+ self.assertDictEqual(data, self._expected_packaging(self.packaging))
def test_data_location(self):
location = self.stock_location
@@ -107,8 +114,8 @@ def test_data_package(self):
"id": package.id,
"name": package.name,
"move_line_count": 1,
- "packaging": self.data.packaging(package.product_packaging_id),
- "weight": 0,
+ "packaging": self._expected_packaging(package.product_packaging_id),
+ "weight": 0.0,
}
self.assertDictEqual(data, expected)
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index 6f3c85d62d..d715d4aefa 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -97,8 +97,7 @@ def test_data_location(self):
def test_data_packaging(self):
data = self.data_detail.packaging(self.packaging)
self.assert_schema(self.schema_detail.packaging(), data)
- expected = {"id": self.packaging.id, "name": self.packaging.name}
- self.assertDictEqual(data, expected)
+ self.assertDictEqual(data, self._expected_packaging(self.packaging))
def test_data_lot(self):
lot = self.env["stock.production.lot"].create(
From 74f8a34f274e105a8e6bd4bd62ab7e37565bfb89 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 29 May 2020 11:51:20 +0200
Subject: [PATCH 240/940] backend: fix test packagings naming
---
shopfloor/tests/test_checkout_change_packaging.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py
index b7d4165837..0fc93b7674 100644
--- a/shopfloor/tests/test_checkout_change_packaging.py
+++ b/shopfloor/tests/test_checkout_change_packaging.py
@@ -65,7 +65,7 @@ def test_list_packaging_ok(self):
data={
"picking": self._picking_summary_data(self.picking),
"package": self._package_data(self.package, self.picking),
- "packagings": [
+ "packaging": [
self._packaging_data(packaging)
for packaging in self.packaging_inner_box
+ self.packaging_box
From 0a31b21091d4538fc34dac3de3b389669c8823b2 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 29 May 2020 12:22:46 +0200
Subject: [PATCH 241/940] backend: fix single pack transfer tests
---
shopfloor/tests/test_single_pack_transfer.py | 30 +++-----------------
1 file changed, 4 insertions(+), 26 deletions(-)
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index bebde9dcc5..dd4dca4695 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -69,32 +69,10 @@ def _response_package_level_data(self, package_level):
return {
"id": package_level.id,
"name": package_level.package_id.name,
- "location_src": {
- "id": self.shelf1.id,
- "name": self.shelf1.name,
- "barcode": self.shelf1.barcode,
- },
- "location_dest": {
- "id": self.shelf2.id,
- "name": self.shelf2.name,
- "barcode": self.shelf2.barcode,
- },
- "picking": {
- "id": self.picking.id,
- "name": self.picking.name,
- "note": None,
- "origin": None,
- "partner": None,
- "move_line_count": len(self.picking.move_line_ids),
- "weight": 2.0,
- },
- "product": {
- "id": self.product_a.id,
- "name": self.product_a.name,
- "default_code": self.product_a.default_code,
- "barcode": self.product_a.barcode,
- "display_name": self.product_a.display_name,
- },
+ "location_src": self.data.location(self.shelf1),
+ "location_dest": self.data.location(self.shelf2),
+ "picking": self.data.picking(self.picking),
+ "product": self.data.product(self.product_a),
}
def test_start(self):
From 03ba0771d2266f67e0bf8170a35223b54b0ea739 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 2 Jun 2020 12:04:11 +0200
Subject: [PATCH 242/940] fixup! Log shopfloor requests in a table
---
shopfloor/data/ir_cron_data.xml | 2 +-
shopfloor/readme/CONFIGURE.rst | 11 +++++++++--
shopfloor/services/service.py | 34 ++++++++++++++++++++++-----------
3 files changed, 33 insertions(+), 14 deletions(-)
diff --git a/shopfloor/data/ir_cron_data.xml b/shopfloor/data/ir_cron_data.xml
index 865d533988..7c5809584b 100644
--- a/shopfloor/data/ir_cron_data.xml
+++ b/shopfloor/data/ir_cron_data.xml
@@ -1,7 +1,7 @@
- AutoVacuum Shopfloor Logs
+ Auto-vacuum Shopfloor Logs
diff --git a/shopfloor/readme/CONFIGURE.rst b/shopfloor/readme/CONFIGURE.rst
index 84321ab431..fe0cc8cdee 100644
--- a/shopfloor/readme/CONFIGURE.rst
+++ b/shopfloor/readme/CONFIGURE.rst
@@ -3,8 +3,15 @@ writeme
Logs retention
--------------
+Logs are kept in database for every REST requests made by a client application.
+They can be used for debugging and monitoring of the activity.
+
+The Logs menu is shown only with Developer tools (``?debug=1``) activated.
+
By default, Shopfloor logs are kept 30 days.
-You can change the duration of the retention by changing
-the System Parameter ``shopfloor.log.retention.days``.
+You can change the duration of the retention by changing the System Parameter
+``shopfloor.log.retention.days``.
If the value is set to 0, the logs are not stored at all.
+
+Logged data is: request URL and method, parameters, headers, result or error.
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index fea0a43b69..51465f1f16 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -23,28 +23,40 @@ class BaseShopfloorService(AbstractComponent):
_collection = "shopfloor.service"
_actions_collection_name = "shopfloor.action"
_expose_model = None
+ # can be overridden to disable logging of requests to DB
+ _log_calls_in_db = True
def dispatch(self, method_name, _id=None, params=None):
+ if not self._db_logging_active():
+ return super().dispatch(method_name, _id=_id, params=params)
+ return self._dispatch_with_db_logging(method_name, _id=_id, params=params)
+
+ def _db_logging_active(self):
+ return (
+ request
+ and self._log_calls_in_db
+ and self.env["shopfloor.log"].logging_active()
+ )
+
+ # TODO logging to DB should be an extra module for base_rest
+ def _dispatch_with_db_logging(self, method_name, _id=None, params=None):
try:
result = super().dispatch(method_name, _id=_id, params=params)
except Exception as err:
self.env.cr.rollback()
with registry(self.env.cr.dbname).cursor() as cr:
env = self.env(cr=cr)
- self._log_call_in_db(env, _id, params, error=err)
+ self._log_call_in_db(env, request, _id, params, error=err)
raise
- self._log_call_in_db(self.env, _id, params, result=result)
+ self._log_call_in_db(self.env, request, _id, params, result=result)
return result
@property
def _log_call_header_strip(self):
return ("Cookie", "Api-Key")
- def _log_call_in_db_values(self, _id, params, result=None, error=None):
- if not request:
- # In tests, we have no http request
- return
- httprequest = request.httprequest
+ def _log_call_in_db_values(self, _request, _id, params, result=None, error=None):
+ httprequest = _request.httprequest
headers = dict(httprequest.headers)
for header_key in self._log_call_header_strip:
if header_key in headers:
@@ -61,10 +73,10 @@ def _log_call_in_db_values(self, _id, params, result=None, error=None):
"state": "success" if result else "failed",
}
- def _log_call_in_db(self, env, _id, params, result=None, error=None):
- if not env["shopfloor.log"].logging_active():
- return
- values = self._log_call_in_db_values(_id, params, result=result, error=error)
+ def _log_call_in_db(self, env, _request, _id, params, result=None, error=None):
+ values = self._log_call_in_db_values(
+ _request, _id, params, result=result, error=error
+ )
if not values:
return
env["shopfloor.log"].sudo().create(values)
From bcd0b9f809c17ff3b308762c2fa993553623495d Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 2 Jun 2020 12:13:12 +0200
Subject: [PATCH 243/940] backend: expose product unit of measure
---
shopfloor/actions/data.py | 1 +
shopfloor/services/schema.py | 12 ++++++++++--
shopfloor/tests/test_actions_data.py | 6 ++++++
3 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 9d53aa23bf..c884cfa71c 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -163,6 +163,7 @@ def _product_parser(self):
"default_code",
"barcode",
("packaging_ids:packaging", self._packaging_parser),
+ ("uom_id:uom", self._simple_record_parser() + ["factor", "rounding"]),
]
def picking_batch(self, record, with_pickings=False, **kw):
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index ebe1523a29..f894f82250 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -24,11 +24,13 @@ def _schema_list_of(self, schema, **kw):
schema.update(kw)
return schema
- def _simple_record(self):
- return {
+ def _simple_record(self, **kw):
+ schema = {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
}
+ schema.update(kw)
+ return schema
def _schema_dict_of(self, schema, **kw):
schema = {
@@ -113,6 +115,12 @@ def product(self):
"default_code": {"type": "string", "nullable": False, "required": True},
"barcode": {"type": "string", "nullable": True, "required": False},
"packaging": self._schema_list_of(self.packaging()),
+ "uom": self._schema_dict_of(
+ self._simple_record(
+ factor={"required": True, "nullable": True, "type": "float"},
+ rounding={"required": True, "nullable": True, "type": "float"},
+ )
+ ),
}
def package(self, with_packaging=False):
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 395de60b80..53deab34d3 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -65,6 +65,12 @@ def _expected_product(self, record, **kw):
"default_code": record.default_code,
"barcode": record.barcode,
"packaging": [self._expected_packaging(x) for x in record.packaging_ids],
+ "uom": {
+ "factor": record.uom_id.factor,
+ "id": record.uom_id.id,
+ "name": record.uom_id.name,
+ "rounding": record.uom_id.rounding,
+ },
}
def _expected_packaging(self, record, **kw):
From 74f591f10f23c0084345ed731721bd810ca63503 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 2 Apr 2020 11:39:23 +0200
Subject: [PATCH 244/940] delivery: implement /scan_deliver
---
shopfloor/models/stock_move_line.py | 4 +
shopfloor/models/stock_quant_package.py | 1 -
shopfloor/services/delivery.py | 193 +++++++++++++++++-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/common.py | 12 ++
shopfloor/tests/test_delivery_base.py | 11 +
shopfloor/tests/test_delivery_scan_deliver.py | 145 +++++++++++++
7 files changed, 360 insertions(+), 7 deletions(-)
create mode 100644 shopfloor/tests/test_delivery_scan_deliver.py
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index ce6d28630d..29c96da23d 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -16,3 +16,7 @@ class StockMoveLine(models.Model):
# we search lines based on their location in some workflows
location_id = fields.Many2one(index=True)
+ package_id = fields.Many2one(index=True)
+
+ # allow domain on picking_id.xxx without too much perf penalty
+ picking_id = fields.Many2one(auto_join=True)
diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py
index 41547158cf..c3bc49511d 100644
--- a/shopfloor/models/stock_quant_package.py
+++ b/shopfloor/models/stock_quant_package.py
@@ -10,7 +10,6 @@ class StockQuantPackage(models.Model):
readonly=True,
help="Technical field. Move lines moving this package.",
)
-
planned_move_line_ids = fields.One2many(
comodel_name="stock.move.line",
inverse_name="result_package_id",
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 9c59f4df34..45143f373e 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -1,3 +1,6 @@
+from odoo import fields
+from odoo.osv import expression
+
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -30,7 +33,15 @@ class Delivery(Component):
_usage = "delivery"
_description = __doc__
- def scan_deliver(self, barcode):
+ def _data_for_stock_picking(self, picking):
+ data_struct = self.actions_for("data")
+ data = data_struct.picking_summary(picking)
+ data.update(
+ {"move_lines": [data_struct.move_line(ml) for ml in picking.move_line_ids]}
+ )
+ return data
+
+ def scan_deliver(self, barcode, picking_id=None):
"""Scan a stock picking or a package/product/lot
When a stock picking is scanned and is partially or fully available, it
@@ -54,11 +65,173 @@ def scan_deliver(self, barcode):
When all the available move lines of the stock picking are done, the
stock picking is set to done.
+ The ``picking_id`` parameter is used to be stateless: if the client
+ sends a wrong barcode, it allows to stay on the last picking with
+ updated data (and we really want to refresh data because several
+ users may work on the same transfer).
+
Transitions:
- * deliver: always return here with the data for the last touched picking
- or no picking if the picking has been set to done
+ * deliver: always return here with the data for the last touched
+ picking or no picking if the picking has been set to done
"""
- return self._response()
+ search = self.actions_for("search")
+ message = self.actions_for("message")
+ picking = search.stock_picking_from_scan(barcode)
+ if picking:
+ if picking.state == "done":
+ return self._response_for_deliver(message=message.already_done())
+ if picking.state not in ("assigned", "partially_available"):
+ return self._response_for_deliver(
+ message=message.stock_picking_not_available(picking)
+ )
+ if picking.picking_type_id != self.picking_type:
+ return self._response_for_deliver(
+ message=message.cannot_move_something_in_picking_type()
+ )
+ return self._response_for_deliver(picking=picking)
+
+ # We should have only a picking_id because the client was working
+ # on it already, so no need to validate the picking type
+ if picking_id:
+ picking = self.env["stock.picking"].browse(picking_id).exists()
+
+ package = search.package_from_scan(barcode)
+ if package:
+ return self._deliver_package(picking, package)
+
+ product = search.product_from_scan(barcode)
+ if product:
+ return self._deliver_product(picking, product)
+
+ lot = search.lot_from_scan(barcode)
+ if lot:
+ return self._deliver_lot(picking, lot)
+
+ return self._response_for_deliver(
+ picking=picking, message=message.barcode_not_found()
+ )
+
+ def _set_lines_done(self, lines):
+ for line in lines:
+ # note: the package level is automatically set to "is_done" when
+ # the qty_done is full
+ line.qty_done = line.product_uom_qty
+
+ def _deliver_package(self, picking, package):
+ message = self.actions_for("message")
+ lines = package.move_line_ids
+ lines = lines.filtered(
+ lambda l: l.state in ("assigned", "partially_available")
+ and l.picking_id.picking_type_id == self.picking_type
+ )
+ if not lines:
+ # TODO tests
+ return self._response_for_deliver(
+ picking=picking, message=message.cannot_move_something_in_picking_type()
+ )
+ # TODO add a message if any of the lines already had a qty_done > 0
+ self._set_lines_done(lines)
+ new_picking = fields.first(lines.mapped("picking_id"))
+ return self._response_for_deliver(picking=new_picking)
+
+ def _lines_base_domain(self):
+ return [
+ # we added auto_join for this, otherwise, the ORM would search all pickings
+ # in the picking type, and then use IN (ids)
+ ("picking_id.picking_type_id", "=", self.picking_type.id),
+ ("qty_done", "=", 0),
+ ]
+
+ def _lines_from_lot_domain(self, product):
+ return expression.AND(
+ [self._lines_base_domain(), [("lot_id", "=", product.id)]]
+ )
+
+ def _lines_from_product_domain(self, product):
+ return expression.AND(
+ [self._lines_base_domain(), [("product_id", "=", product.id)]]
+ )
+
+ def _deliver_product(self, picking, product):
+ message = self.actions_for("message")
+ if product.tracking in ("lot", "serial"):
+ # TODO test
+ return self._response_deliver(
+ picking, message=message.scan_lot_on_product_tracked_by_lot()
+ )
+
+ lines = self.env["stock.move.line"].search(
+ self._lines_from_product_domain(product)
+ )
+ if not lines:
+ # TODO not found
+ pass
+
+ new_picking = fields.first(lines.mapped("picking_id"))
+ # 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.
+ # If the product is only in one package though, scanning the product selects
+ # the package.
+ packages = lines.mapped("package_id")
+ # Do not use mapped here: we want to see if we have more than one package,
+ # but also if we have one product as a package and the same product as
+ # a unit in another line. In both cases, we want the user to scan the
+ # package.
+ if packages and len({l.package_id for l in lines}) > 1:
+ return self._response_for_deliver(
+ new_picking, message=message.product_multiple_packages_scan_package()
+ )
+ elif packages:
+ # we have 1 package
+ # TODO if the package contain more than one product, set them to moved
+ # as well or forbid it (maybe could be done in _set_lines_done)
+ pass
+ self._set_lines_done(lines)
+ return self._response_for_deliver(new_picking)
+
+ def _deliver_lot(self, picking, lot):
+ message = self.actions_for("message")
+ lines = self.env["stock.move.line"].search(self._lines_from_lot_domain(lot))
+ if not lines:
+ # TODO not found
+ pass
+
+ new_picking = fields.first(lines.mapped("picking_id"))
+
+ # When lots are as units outside of packages, we can select them for
+ # packing, but if they are in a package, we want the user to scan the packages.
+ # If the product is only in one package though, scanning the lot selects
+ # the package.
+ packages = lines.mapped("package_id")
+ # Do not use mapped here: we want to see if we have more than one
+ # package, but also if we have one lot as a package and the same lot as
+ # a unit in another line. In both cases, we want the user to scan the
+ # package.
+ if packages and len({l.package_id for l in lines}) > 1:
+ return self._response_for_deliver(
+ new_picking, message=message.lot_multiple_packages_scan_package()
+ )
+ elif packages:
+ # we have 1 package
+ # TODO if the package contain more than one product, set them to moved
+ # as well or forbid it (maybe could be done in _set_lines_done)
+ pass
+
+ self._set_lines_done(lines)
+ return self._response_for_deliver(new_picking)
+
+ def _response_for_deliver(self, picking=None, message=None):
+ """Transition to the 'deliver' state
+
+ If no picking is passed, the screen shows an empty screen
+ """
+ return self._response(
+ next_state="deliver",
+ data={
+ "picking": self._data_for_stock_picking(picking) if picking else None
+ },
+ message=message,
+ )
def list_stock_picking(self):
"""Return the list of stock pickings for the picking type
@@ -143,7 +316,15 @@ class ShopfloorDeliveryValidator(Component):
_usage = "delivery.validator"
def scan_deliver(self):
- return {"barcode": {"required": True, "type": "string"}}
+ return {
+ "barcode": {"required": True, "type": "string"},
+ "picking_id": {
+ "coerce": to_int,
+ "required": False,
+ "nullable": True,
+ "type": "integer",
+ },
+ }
def list_stock_picking(self):
return {}
@@ -211,7 +392,7 @@ def _schema_deliver(self):
}
}
)
- return {"picking": {"type": "dict", "required": False, "schema": schema}}
+ return {"picking": {"type": "dict", "nullable": True, "schema": schema}}
@property
def _schema_selection_list(self):
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 640b06ae81..7e1381878b 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -29,3 +29,4 @@
from . import test_checkout_cancel_line
from . import test_checkout_done
from . import test_delivery_base
+from . import test_delivery_scan_deliver
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 1f83cde1eb..97806a081f 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -231,6 +231,18 @@ def setUpClassBaseData(cls):
}
)
)
+ cls.product_e = cls.env["product.product"].create(
+ {
+ "name": "Product E",
+ "type": "product",
+ "default_code": "E",
+ "barcode": "E",
+ "weight": 3,
+ }
+ )
+ cls.product_e_packaging = cls.env["product.packaging"].create(
+ {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductEBox"}
+ )
def assert_response(
self, response, next_state=None, message=None, data=None, popup=None
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
index a96ff00841..3a6fdae03e 100644
--- a/shopfloor/tests/test_delivery_base.py
+++ b/shopfloor/tests/test_delivery_base.py
@@ -19,3 +19,14 @@ def setUp(self):
super().setUp()
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="delivery")
+
+ def _stock_picking_data(self, picking):
+ return self.service._data_for_stock_picking(picking)
+
+ def assert_response_deliver(self, response, picking=None, message=None):
+ self.assert_response(
+ response,
+ next_state="deliver",
+ data={"picking": self._stock_picking_data(picking) if picking else None},
+ message=message,
+ )
diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py
new file mode 100644
index 0000000000..d02c16ae41
--- /dev/null
+++ b/shopfloor/tests/test_delivery_scan_deliver.py
@@ -0,0 +1,145 @@
+from .test_delivery_base import DeliveryCommonCase
+
+
+class DeliveryScanDeliverCase(DeliveryCommonCase):
+ """Tests for /scan_deliver"""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.product_e.tracking = "lot"
+ cls.picking = picking = cls._create_picking(
+ lines=[
+ # we'll put A and B in a single package
+ (cls.product_a, 10),
+ (cls.product_b, 10),
+ # C alone in a package
+ (cls.product_c, 10),
+ # D as raw product
+ (cls.product_d, 10),
+ # E as raw product with a lot
+ (cls.product_e, 10),
+ ]
+ )
+ cls.pack1_moves = picking.move_lines[:2]
+ cls.pack2_move = picking.move_lines[2]
+ cls.raw_move = picking.move_lines[3]
+ cls.raw_lot_move = picking.move_lines[4]
+ cls._fill_stock_for_moves(cls.pack1_moves, in_package=True)
+ cls._fill_stock_for_moves(cls.pack2_move, in_package=True)
+ cls._fill_stock_for_moves(cls.raw_move)
+ cls._fill_stock_for_moves(cls.raw_lot_move, in_lot=True)
+ picking.action_assign()
+
+ def test_scan_deliver_scan_picking_ok(self):
+ response = self.service.dispatch(
+ "scan_deliver", params={"barcode": self.picking.name, "picking_id": None}
+ )
+ self.assert_response_deliver(response, picking=self.picking)
+
+ def test_scan_deliver_error_barcode_not_found(self):
+ response = self.service.dispatch(
+ "scan_deliver", params={"barcode": "NO VALID BARCODE", "picking_id": None}
+ )
+ self.assert_response_deliver(
+ response, message={"message_type": "error", "message": "Barcode not found"}
+ )
+
+ def test_scan_deliver_error_barcode_not_found_keep_picking(self):
+ response = self.service.dispatch(
+ "scan_deliver",
+ params={"barcode": "NO VALID BARCODE", "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ # if the client was working on a picking (it sends picking_id, then
+ # send refreshed data)
+ picking=self.picking,
+ message={"message_type": "error", "message": "Barcode not found"},
+ )
+
+ def assert_qty_done(self, move_lines):
+ self.assertRecordValues(
+ move_lines, [{"qty_done": line.product_uom_qty} for line in move_lines]
+ )
+ package_level = move_lines.package_level_id
+ if package_level:
+ # we have a package level only when there is a package
+ self.assertRecordValues(package_level, [{"is_done": True}])
+
+ def _test_scan_set_done_ok(self, move_lines, barcode):
+ response = self.service.dispatch("scan_deliver", params={"barcode": barcode})
+ self.assert_qty_done(move_lines)
+ self.assert_response_deliver(response, picking=self.picking)
+
+ def test_scan_deliver_scan_package_ok(self):
+ move_lines = self.pack1_moves.mapped("move_line_ids")
+ package = move_lines.mapped("package_id")
+ self._test_scan_set_done_ok(move_lines, package.name)
+
+ def test_scan_deliver_scan_product_in_package_ok(self):
+ self._test_scan_set_done_ok(
+ self.pack2_move.mapped("move_line_ids"), self.product_c.barcode
+ )
+
+ def test_scan_deliver_scan_raw_product_ok(self):
+ self._test_scan_set_done_ok(
+ self.raw_move.mapped("move_line_ids"), self.product_d.barcode
+ )
+
+ def test_scan_deliver_scan_lot_ok(self):
+ move_lines = self.raw_lot_move.move_line_ids
+ lot = move_lines.lot_id
+ self._test_scan_set_done_ok(move_lines, lot.name)
+
+ # TODO test for product in different packages
+ # TODO test for product in one package but the package contains a product
+ # in different packages
+
+
+class DeliveryScanDeliverSpecialCase(DeliveryCommonCase):
+ """Special cases with different setup for /scan_deliver"""
+
+ def test_scan_deliver_error_picking_wrong_type(self):
+ picking = self._create_picking(
+ picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)]
+ )
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ response = self.service.dispatch(
+ "scan_deliver", params={"barcode": picking.name}
+ )
+ self.assert_response_deliver(
+ response,
+ message={
+ "message_type": "error",
+ "message": "You cannot move this using this menu.",
+ },
+ )
+
+ def test_scan_deliver_error_picking_unavailable(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ response = self.service.dispatch(
+ "scan_deliver", params={"barcode": picking.name}
+ )
+ self.assert_response_deliver(
+ response,
+ message={
+ "message_type": "error",
+ "message": "Transfer {} is not available.".format(picking.name),
+ },
+ )
+
+ def test_scan_deliver_error_picking_already_done(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ picking.move_line_ids.qty_done = picking.move_line_ids.product_uom_qty
+ picking.action_done()
+ response = self.service.dispatch(
+ "scan_deliver", params={"barcode": picking.name}
+ )
+ self.assert_response_deliver(
+ response,
+ message={"message_type": "info", "message": "Operation already processed."},
+ )
From c61a75fc2be6e88986dad2a3df79f088c395ba9b Mon Sep 17 00:00:00 2001
From: sebalix
Date: Wed, 27 May 2020 15:36:30 +0200
Subject: [PATCH 245/940] delivery: update code to fit last changes
---
shopfloor/services/delivery.py | 75 +++++++++++--------
shopfloor/tests/common.py | 12 ---
shopfloor/tests/test_delivery_base.py | 24 ++++++
shopfloor/tests/test_delivery_scan_deliver.py | 14 ++--
4 files changed, 73 insertions(+), 52 deletions(-)
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 45143f373e..8d16752070 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -33,11 +33,35 @@ class Delivery(Component):
_usage = "delivery"
_description = __doc__
+ @property
+ def data_struct(self):
+ return self.actions_for("data")
+
+ @property
+ def msg_store(self):
+ return self.actions_for("message")
+
+ def _response_for_deliver(self, picking=None, message=None):
+ """Transition to the 'deliver' state
+
+ If no picking is passed, the screen shows an empty screen
+ """
+ return self._response(
+ next_state="deliver",
+ data={
+ "picking": self._data_for_stock_picking(picking) if picking else None
+ },
+ message=message,
+ )
+
def _data_for_stock_picking(self, picking):
- data_struct = self.actions_for("data")
- data = data_struct.picking_summary(picking)
+ data = self.data_struct.picking(picking)
data.update(
- {"move_lines": [data_struct.move_line(ml) for ml in picking.move_line_ids]}
+ {
+ "move_lines": [
+ self.data_struct.move_line(ml) for ml in picking.move_line_ids
+ ]
+ }
)
return data
@@ -75,18 +99,17 @@ def scan_deliver(self, barcode, picking_id=None):
picking or no picking if the picking has been set to done
"""
search = self.actions_for("search")
- message = self.actions_for("message")
- picking = search.stock_picking_from_scan(barcode)
+ picking = search.picking_from_scan(barcode)
if picking:
if picking.state == "done":
- return self._response_for_deliver(message=message.already_done())
+ return self._response_for_deliver(message=self.msg_store.already_done())
if picking.state not in ("assigned", "partially_available"):
return self._response_for_deliver(
- message=message.stock_picking_not_available(picking)
+ message=self.msg_store.stock_picking_not_available(picking)
)
- if picking.picking_type_id != self.picking_type:
+ if picking.picking_type_id not in self.picking_types:
return self._response_for_deliver(
- message=message.cannot_move_something_in_picking_type()
+ message=self.msg_store.cannot_move_something_in_picking_type()
)
return self._response_for_deliver(picking=picking)
@@ -108,7 +131,7 @@ def scan_deliver(self, barcode, picking_id=None):
return self._deliver_lot(picking, lot)
return self._response_for_deliver(
- picking=picking, message=message.barcode_not_found()
+ picking=picking, message=self.msg_store.barcode_not_found()
)
def _set_lines_done(self, lines):
@@ -118,16 +141,16 @@ def _set_lines_done(self, lines):
line.qty_done = line.product_uom_qty
def _deliver_package(self, picking, package):
- message = self.actions_for("message")
lines = package.move_line_ids
lines = lines.filtered(
lambda l: l.state in ("assigned", "partially_available")
- and l.picking_id.picking_type_id == self.picking_type
+ and l.picking_id.picking_type_id in self.picking_types
)
if not lines:
# TODO tests
return self._response_for_deliver(
- picking=picking, message=message.cannot_move_something_in_picking_type()
+ picking=picking,
+ message=self.msg_store.cannot_move_something_in_picking_type(),
)
# TODO add a message if any of the lines already had a qty_done > 0
self._set_lines_done(lines)
@@ -138,7 +161,7 @@ def _lines_base_domain(self):
return [
# we added auto_join for this, otherwise, the ORM would search all pickings
# in the picking type, and then use IN (ids)
- ("picking_id.picking_type_id", "=", self.picking_type.id),
+ ("picking_id.picking_type_id", "in", self.picking_types.ids),
("qty_done", "=", 0),
]
@@ -153,11 +176,10 @@ def _lines_from_product_domain(self, product):
)
def _deliver_product(self, picking, product):
- message = self.actions_for("message")
if product.tracking in ("lot", "serial"):
# TODO test
- return self._response_deliver(
- picking, message=message.scan_lot_on_product_tracked_by_lot()
+ return self._response_for_deliver(
+ picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot()
)
lines = self.env["stock.move.line"].search(
@@ -179,7 +201,8 @@ def _deliver_product(self, picking, product):
# package.
if packages and len({l.package_id for l in lines}) > 1:
return self._response_for_deliver(
- new_picking, message=message.product_multiple_packages_scan_package()
+ new_picking,
+ message=self.msg_store.product_multiple_packages_scan_package(),
)
elif packages:
# we have 1 package
@@ -190,7 +213,6 @@ def _deliver_product(self, picking, product):
return self._response_for_deliver(new_picking)
def _deliver_lot(self, picking, lot):
- message = self.actions_for("message")
lines = self.env["stock.move.line"].search(self._lines_from_lot_domain(lot))
if not lines:
# TODO not found
@@ -209,7 +231,7 @@ def _deliver_lot(self, picking, lot):
# package.
if packages and len({l.package_id for l in lines}) > 1:
return self._response_for_deliver(
- new_picking, message=message.lot_multiple_packages_scan_package()
+ new_picking, message=self.msg_store.lot_multiple_packages_scan_package()
)
elif packages:
# we have 1 package
@@ -220,19 +242,6 @@ def _deliver_lot(self, picking, lot):
self._set_lines_done(lines)
return self._response_for_deliver(new_picking)
- def _response_for_deliver(self, picking=None, message=None):
- """Transition to the 'deliver' state
-
- If no picking is passed, the screen shows an empty screen
- """
- return self._response(
- next_state="deliver",
- data={
- "picking": self._data_for_stock_picking(picking) if picking else None
- },
- message=message,
- )
-
def list_stock_picking(self):
"""Return the list of stock pickings for the picking type
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 97806a081f..1f83cde1eb 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -231,18 +231,6 @@ def setUpClassBaseData(cls):
}
)
)
- cls.product_e = cls.env["product.product"].create(
- {
- "name": "Product E",
- "type": "product",
- "default_code": "E",
- "barcode": "E",
- "weight": 3,
- }
- )
- cls.product_e_packaging = cls.env["product.packaging"].create(
- {"name": "Box", "product_id": cls.product_d.id, "barcode": "ProductEBox"}
- )
def assert_response(
self, response, next_state=None, message=None, data=None, popup=None
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
index 3a6fdae03e..8fb4e27169 100644
--- a/shopfloor/tests/test_delivery_base.py
+++ b/shopfloor/tests/test_delivery_base.py
@@ -14,6 +14,30 @@ def setUpClassVars(cls, *args, **kwargs):
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
cls.wh.sudo().delivery_steps = "pick_pack_ship"
+ cls.product_e = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product E",
+ "type": "product",
+ "default_code": "E",
+ "barcode": "E",
+ "weight": 3,
+ }
+ )
+ )
+ cls.product_e_packaging = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "name": "Box",
+ "product_id": cls.product_e.id,
+ "barcode": "ProductEBox",
+ }
+ )
+ )
def setUp(self):
super().setUp()
diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py
index d02c16ae41..12445e7538 100644
--- a/shopfloor/tests/test_delivery_scan_deliver.py
+++ b/shopfloor/tests/test_delivery_scan_deliver.py
@@ -5,8 +5,8 @@ class DeliveryScanDeliverCase(DeliveryCommonCase):
"""Tests for /scan_deliver"""
@classmethod
- def setUpClass(cls):
- super().setUpClass()
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
cls.product_e.tracking = "lot"
cls.picking = picking = cls._create_picking(
lines=[
@@ -42,7 +42,7 @@ def test_scan_deliver_error_barcode_not_found(self):
"scan_deliver", params={"barcode": "NO VALID BARCODE", "picking_id": None}
)
self.assert_response_deliver(
- response, message={"message_type": "error", "message": "Barcode not found"}
+ response, message={"message_type": "error", "body": "Barcode not found"}
)
def test_scan_deliver_error_barcode_not_found_keep_picking(self):
@@ -55,7 +55,7 @@ def test_scan_deliver_error_barcode_not_found_keep_picking(self):
# if the client was working on a picking (it sends picking_id, then
# send refreshed data)
picking=self.picking,
- message={"message_type": "error", "message": "Barcode not found"},
+ message={"message_type": "error", "body": "Barcode not found"},
)
def assert_qty_done(self, move_lines):
@@ -113,7 +113,7 @@ def test_scan_deliver_error_picking_wrong_type(self):
response,
message={
"message_type": "error",
- "message": "You cannot move this using this menu.",
+ "body": "You cannot move this using this menu.",
},
)
@@ -126,7 +126,7 @@ def test_scan_deliver_error_picking_unavailable(self):
response,
message={
"message_type": "error",
- "message": "Transfer {} is not available.".format(picking.name),
+ "body": "Transfer {} is not available.".format(picking.name),
},
)
@@ -141,5 +141,5 @@ def test_scan_deliver_error_picking_already_done(self):
)
self.assert_response_deliver(
response,
- message={"message_type": "info", "message": "Operation already processed."},
+ message={"message_type": "info", "body": "Operation already processed."},
)
From a886f03880a96c868f67e713cc1365c121a174b2 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Thu, 28 May 2020 11:18:38 +0200
Subject: [PATCH 246/940] delivery: complete 'scan_deliver' endpoint and
improve test coverage
---
shopfloor/actions/message.py | 36 +++++-
shopfloor/services/delivery.py | 36 +++---
shopfloor/tests/test_delivery_base.py | 24 ++++
shopfloor/tests/test_delivery_scan_deliver.py | 106 +++++++++++++++++-
4 files changed, 182 insertions(+), 20 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 30bc8e10fd..9454fdd7f5 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -209,7 +209,35 @@ def product_multiple_packages_scan_package(self):
return {
"message_type": "warning",
"body": _(
- "This product is part of multiple packages, please scan a package."
+ _("This product is part of multiple packages, please scan a package.")
+ ),
+ }
+
+ def product_mixed_package_scan_package(self):
+ return {
+ "message_type": "warning",
+ "body": _(
+ _(
+ "This product is part of a package with other products, "
+ "please scan a package."
+ )
+ ),
+ }
+
+ def product_not_found_in_pickings(self):
+ return {
+ "message_type": "warning",
+ "body": _("No product found among current transfers."),
+ }
+
+ def lot_mixed_package_scan_package(self):
+ return {
+ "message_type": "warning",
+ "body": _(
+ _(
+ "This lot is part of a package with other products, "
+ "please scan a package."
+ )
),
}
@@ -219,6 +247,12 @@ def lot_multiple_packages_scan_package(self):
"body": _("This lot is part of multiple packages, please scan a package."),
}
+ def lot_not_found_in_pickings(self):
+ return {
+ "message_type": "warning",
+ "body": _("No lot found among current transfers."),
+ }
+
def batch_transfer_complete(self):
return {
"message_type": "success",
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 8d16752070..d370fdc6e4 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -147,7 +147,6 @@ def _deliver_package(self, picking, package):
and l.picking_id.picking_type_id in self.picking_types
)
if not lines:
- # TODO tests
return self._response_for_deliver(
picking=picking,
message=self.msg_store.cannot_move_something_in_picking_type(),
@@ -165,10 +164,8 @@ def _lines_base_domain(self):
("qty_done", "=", 0),
]
- def _lines_from_lot_domain(self, product):
- return expression.AND(
- [self._lines_base_domain(), [("lot_id", "=", product.id)]]
- )
+ def _lines_from_lot_domain(self, lot):
+ return expression.AND([self._lines_base_domain(), [("lot_id", "=", lot.id)]])
def _lines_from_product_domain(self, product):
return expression.AND(
@@ -177,7 +174,6 @@ def _lines_from_product_domain(self, product):
def _deliver_product(self, picking, product):
if product.tracking in ("lot", "serial"):
- # TODO test
return self._response_for_deliver(
picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot()
)
@@ -186,8 +182,9 @@ def _deliver_product(self, picking, product):
self._lines_from_product_domain(product)
)
if not lines:
- # TODO not found
- pass
+ return self._response_for_deliver(
+ picking, message=self.msg_store.product_not_found_in_pickings()
+ )
new_picking = fields.first(lines.mapped("picking_id"))
# When products are as units outside of packages, we can select them for
@@ -206,17 +203,21 @@ def _deliver_product(self, picking, product):
)
elif packages:
# we have 1 package
- # TODO if the package contain more than one product, set them to moved
- # as well or forbid it (maybe could be done in _set_lines_done)
- pass
+ # abort the operation if the package contain more than one product
+ if len(packages.mapped("quant_ids.product_id")) > 1:
+ return self._response_for_deliver(
+ new_picking,
+ message=self.msg_store.product_mixed_package_scan_package(),
+ )
self._set_lines_done(lines)
return self._response_for_deliver(new_picking)
def _deliver_lot(self, picking, lot):
lines = self.env["stock.move.line"].search(self._lines_from_lot_domain(lot))
if not lines:
- # TODO not found
- pass
+ return self._response_for_deliver(
+ picking, message=self.msg_store.lot_not_found_in_pickings()
+ )
new_picking = fields.first(lines.mapped("picking_id"))
@@ -235,9 +236,12 @@ def _deliver_lot(self, picking, lot):
)
elif packages:
# we have 1 package
- # TODO if the package contain more than one product, set them to moved
- # as well or forbid it (maybe could be done in _set_lines_done)
- pass
+ # abort the operation if the package contain more than one product
+ if len(packages.quant_ids) > 1:
+ return self._response_for_deliver(
+ new_picking,
+ message=self.msg_store.lot_mixed_package_scan_package(),
+ )
self._set_lines_done(lines)
return self._response_for_deliver(new_picking)
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
index 8fb4e27169..7864982565 100644
--- a/shopfloor/tests/test_delivery_base.py
+++ b/shopfloor/tests/test_delivery_base.py
@@ -38,6 +38,30 @@ def setUpClassBaseData(cls, *args, **kwargs):
}
)
)
+ cls.product_f = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product F",
+ "type": "product",
+ "default_code": "F",
+ "barcode": "F",
+ "weight": 3,
+ }
+ )
+ )
+ cls.product_f_packaging = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "name": "Box",
+ "product_id": cls.product_f.id,
+ "barcode": "ProductFBox",
+ }
+ )
+ )
def setUp(self):
super().setUp()
diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py
index 12445e7538..024feebd01 100644
--- a/shopfloor/tests/test_delivery_scan_deliver.py
+++ b/shopfloor/tests/test_delivery_scan_deliver.py
@@ -19,17 +19,53 @@ def setUpClassBaseData(cls):
(cls.product_d, 10),
# E as raw product with a lot
(cls.product_e, 10),
+ # F in two different packages
+ (cls.product_f, 10),
]
)
cls.pack1_moves = picking.move_lines[:2]
cls.pack2_move = picking.move_lines[2]
+ cls.pack3_move = picking.move_lines[5]
cls.raw_move = picking.move_lines[3]
cls.raw_lot_move = picking.move_lines[4]
cls._fill_stock_for_moves(cls.pack1_moves, in_package=True)
cls._fill_stock_for_moves(cls.pack2_move, in_package=True)
cls._fill_stock_for_moves(cls.raw_move)
cls._fill_stock_for_moves(cls.raw_lot_move, in_lot=True)
+ # Set a lot for A for the mixed package (A + B)
+ cls.product_a_lot = cls.env["stock.production.lot"].create(
+ {"product_id": cls.product_a.id, "company_id": cls.env.company.id}
+ )
+ cls.product_a_quant = cls.env["stock.quant"].search(
+ [("product_id", "=", cls.product_a.id)]
+ )
+ cls.product_a_quant.sudo().lot_id = cls.product_a_lot
+ # Fill stock for F moves (two packages)
+ for __ in range(2):
+ product_f_pkg = cls.env["stock.quant.package"].create({})
+ cls._update_qty_in_location(
+ cls.pack3_move.location_id,
+ cls.pack3_move.product_id,
+ 5,
+ package=product_f_pkg,
+ )
picking.action_assign()
+ # Some records not related at all to the processed picking
+ cls.free_package = cls.env["stock.quant.package"].create(
+ {"name": "FREE_PACKAGE"}
+ )
+ cls.free_lot = cls.env["stock.production.lot"].create(
+ {
+ "name": "FREE_LOT",
+ "product_id": cls.product_a.id,
+ "company_id": cls.env.company.id,
+ }
+ )
+ cls.free_product = (
+ cls.env["product.product"]
+ .sudo()
+ .create({"name": "FREE_PRODUCT", "barcode": "FREE_PRODUCT"})
+ )
def test_scan_deliver_scan_picking_ok(self):
response = self.service.dispatch(
@@ -77,24 +113,88 @@ def test_scan_deliver_scan_package_ok(self):
package = move_lines.mapped("package_id")
self._test_scan_set_done_ok(move_lines, package.name)
+ def test_scan_deliver_scan_package_no_move_lines(self):
+ response = self.service.dispatch(
+ "scan_deliver",
+ params={"barcode": self.free_package.name, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.cannot_move_something_in_picking_type(),
+ )
+
def test_scan_deliver_scan_product_in_package_ok(self):
self._test_scan_set_done_ok(
self.pack2_move.mapped("move_line_ids"), self.product_c.barcode
)
+ def test_scan_deliver_scan_product_in_multiple_packages(self):
+ response = self.service.dispatch(
+ "scan_deliver",
+ params={"barcode": self.product_f.barcode, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.product_multiple_packages_scan_package(),
+ )
+
+ def test_scan_deliver_scan_product_in_mixed_package(self):
+ response = self.service.dispatch(
+ "scan_deliver",
+ params={"barcode": self.product_a.barcode, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.product_mixed_package_scan_package(),
+ )
+
+ def test_scan_deliver_scan_product_tracked_by_lot(self):
+ response = self.service.dispatch(
+ "scan_deliver",
+ params={"barcode": self.product_e.barcode, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.scan_lot_on_product_tracked_by_lot(),
+ )
+
def test_scan_deliver_scan_raw_product_ok(self):
self._test_scan_set_done_ok(
self.raw_move.mapped("move_line_ids"), self.product_d.barcode
)
+ def test_scan_deliver_scan_product_not_found(self):
+ response = self.service.dispatch(
+ "scan_deliver", params={"barcode": self.free_product.barcode}
+ )
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.product_not_found_in_pickings(),
+ )
+
def test_scan_deliver_scan_lot_ok(self):
move_lines = self.raw_lot_move.move_line_ids
lot = move_lines.lot_id
self._test_scan_set_done_ok(move_lines, lot.name)
- # TODO test for product in different packages
- # TODO test for product in one package but the package contains a product
- # in different packages
+ def test_scan_deliver_scan_lot_not_found(self):
+ response = self.service.dispatch("scan_deliver", params={"barcode": "FREE_LOT"})
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.lot_not_found_in_pickings(),
+ )
+
+ def test_scan_deliver_scan_lot_in_mixed_package(self):
+ response = self.service.dispatch(
+ "scan_deliver", params={"barcode": self.product_a_lot.name}
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.lot_mixed_package_scan_package(),
+ )
class DeliveryScanDeliverSpecialCase(DeliveryCommonCase):
From 8eb63cc8075d2a6390772eccfe1fec253f46e3bd Mon Sep 17 00:00:00 2001
From: sebalix
Date: Fri, 29 May 2020 16:17:48 +0200
Subject: [PATCH 247/940] delivery: implement 'set_qty_done_pack' endpoint
---
shopfloor/actions/message.py | 6 +
shopfloor/services/delivery.py | 64 +++++++--
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_delivery_base.py | 23 +++
shopfloor/tests/test_delivery_scan_deliver.py | 9 --
.../tests/test_delivery_set_qty_done_pack.py | 133 ++++++++++++++++++
6 files changed, 216 insertions(+), 20 deletions(-)
create mode 100644 shopfloor/tests/test_delivery_set_qty_done_pack.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 9454fdd7f5..ddb9f7c712 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -75,6 +75,12 @@ def stock_picking_not_found(self):
"body": _("This transfer does not exist anymore."),
}
+ def package_not_found(self):
+ return {
+ "message_type": "error",
+ "body": _("This package does not exist anymore."),
+ }
+
def record_not_found(self):
return {
"message_type": "error",
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index d370fdc6e4..1bfacf1ac1 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -65,6 +65,26 @@ def _data_for_stock_picking(self, picking):
)
return data
+ def _check_picking_status(self, picking):
+ """Check if `picking` can be processed.
+
+ If the picking is already done, canceled or didn't belong to the
+ expected picking type, a response is returned.
+
+ Transitions:
+ * deliver: always return here with updated data
+ """
+ if picking.state == "done":
+ return self._response_for_deliver(message=self.msg_store.already_done())
+ if picking.state not in ("assigned", "partially_available"):
+ return self._response_for_deliver(
+ message=self.msg_store.stock_picking_not_available(picking)
+ )
+ if picking.picking_type_id not in self.picking_types:
+ return self._response_for_deliver(
+ message=self.msg_store.cannot_move_something_in_picking_type()
+ )
+
def scan_deliver(self, barcode, picking_id=None):
"""Scan a stock picking or a package/product/lot
@@ -101,16 +121,9 @@ def scan_deliver(self, barcode, picking_id=None):
search = self.actions_for("search")
picking = search.picking_from_scan(barcode)
if picking:
- if picking.state == "done":
- return self._response_for_deliver(message=self.msg_store.already_done())
- if picking.state not in ("assigned", "partially_available"):
- return self._response_for_deliver(
- message=self.msg_store.stock_picking_not_available(picking)
- )
- if picking.picking_type_id not in self.picking_types:
- return self._response_for_deliver(
- message=self.msg_store.cannot_move_something_in_picking_type()
- )
+ response = self._check_picking_status(picking)
+ if response:
+ return response
return self._response_for_deliver(picking=picking)
# We should have only a picking_id because the client was working
@@ -246,6 +259,19 @@ def _deliver_lot(self, picking, lot):
self._set_lines_done(lines)
return self._response_for_deliver(new_picking)
+ def _action_picking_done(self, picking):
+ """Try to validate the stock picking if all its lines have been processed.
+
+ Return `True` if the picking has been validated successfully.
+ """
+ move_lines_done = all(
+ [line.qty_done >= line.product_uom_qty for line in picking.move_line_ids]
+ )
+ if move_lines_done:
+ picking.action_done()
+ return True
+ return False
+
def list_stock_picking(self):
"""Return the list of stock pickings for the picking type
@@ -276,7 +302,23 @@ def set_qty_done_pack(self, picking_id, package_id):
Transitions:
* deliver: always return here with updated data
"""
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id).exists()
+ if picking:
+ response = self._check_picking_status(picking)
+ if response:
+ return response
+ else:
+ return self._response_for_deliver(
+ message=self.msg_store.stock_picking_not_found()
+ )
+ package = self.env["stock.quant.package"].browse(package_id).exists()
+ if package:
+ response = self._deliver_package(picking, package)
+ self._action_picking_done(picking)
+ return response
+ return self._response_for_deliver(
+ picking=picking, message=self.msg_store.package_not_found()
+ )
def set_qty_done_line(self, picking_id, line_id):
"""Set a move line to "Done"
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 7e1381878b..5a24485bdf 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -30,3 +30,4 @@
from . import test_checkout_done
from . import test_delivery_base
from . import test_delivery_scan_deliver
+from . import test_delivery_set_qty_done_pack
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
index 7864982565..36f9225190 100644
--- a/shopfloor/tests/test_delivery_base.py
+++ b/shopfloor/tests/test_delivery_base.py
@@ -78,3 +78,26 @@ def assert_response_deliver(self, response, picking=None, message=None):
data={"picking": self._stock_picking_data(picking) if picking else None},
message=message,
)
+
+ def assert_qty_done(self, move_lines, qties=None):
+ """Ensure that the quantities done are the expected ones.
+
+ If `qties` is not defined, the expected qties are `product_uom_qty`
+ of the move lines.
+ `qties` parameter is a list of move lines qty (same order).
+ """
+ if qties:
+ assert len(move_lines) == len(qties), "'qties' doesn't match 'move_lines'"
+ expected_qties = []
+ for qty in qties:
+ expected_qties.append({"qty_done": qty})
+ else:
+ expected_qties = [{"qty_done": line.product_uom_qty} for line in move_lines]
+ self.assertRecordValues(move_lines, expected_qties)
+ package_level = move_lines.package_level_id
+ if package_level:
+ values = [{"is_done": True}]
+ if qties:
+ values = [{"is_done": bool(qty)} for qty in qties]
+ # we have a package level only when there is a package
+ self.assertRecordValues(package_level, values)
diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py
index 024feebd01..e75e285201 100644
--- a/shopfloor/tests/test_delivery_scan_deliver.py
+++ b/shopfloor/tests/test_delivery_scan_deliver.py
@@ -94,15 +94,6 @@ def test_scan_deliver_error_barcode_not_found_keep_picking(self):
message={"message_type": "error", "body": "Barcode not found"},
)
- def assert_qty_done(self, move_lines):
- self.assertRecordValues(
- move_lines, [{"qty_done": line.product_uom_qty} for line in move_lines]
- )
- package_level = move_lines.package_level_id
- if package_level:
- # we have a package level only when there is a package
- self.assertRecordValues(package_level, [{"is_done": True}])
-
def _test_scan_set_done_ok(self, move_lines, barcode):
response = self.service.dispatch("scan_deliver", params={"barcode": barcode})
self.assert_qty_done(move_lines)
diff --git a/shopfloor/tests/test_delivery_set_qty_done_pack.py b/shopfloor/tests/test_delivery_set_qty_done_pack.py
new file mode 100644
index 0000000000..aaee4f9a6d
--- /dev/null
+++ b/shopfloor/tests/test_delivery_set_qty_done_pack.py
@@ -0,0 +1,133 @@
+# Copyright 2020 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+from .test_delivery_base import DeliveryCommonCase
+
+
+class DeliverySetQtyDonePackCase(DeliveryCommonCase):
+ """Tests for /set_qty_done_pack"""
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.picking = picking = cls._create_picking(
+ lines=[
+ # we'll put A and B in a single package
+ (cls.product_a, 10),
+ (cls.product_b, 10),
+ # C alone in a package
+ (cls.product_c, 10),
+ # D in two different packages
+ (cls.product_d, 10),
+ ]
+ )
+ cls.pack1_moves = picking.move_lines[:2]
+ cls.pack2_move = picking.move_lines[2]
+ cls.pack3_move = picking.move_lines[3]
+ cls._fill_stock_for_moves(cls.pack1_moves, in_package=True)
+ cls._fill_stock_for_moves(cls.pack2_move, in_package=True)
+ # Fill stock for D moves (two packages)
+ for __ in range(2):
+ product_d_pkg = cls.env["stock.quant.package"].create({})
+ cls._update_qty_in_location(
+ cls.pack3_move.location_id,
+ cls.pack3_move.product_id,
+ 5,
+ package=product_d_pkg,
+ )
+ picking.action_assign()
+
+ def _test_set_qty_done_pack_ok(self, move_lines, package, qties=None):
+ response = self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package.id, "picking_id": self.picking.id},
+ )
+ self.assert_qty_done(move_lines, qties=qties)
+ self.assert_response_deliver(response, picking=self.picking)
+
+ def test_set_qty_done_pack_picking_not_found(self):
+ move_lines = self.pack1_moves.mapped("move_line_ids")
+ package = move_lines.mapped("package_id")
+ response = self.service.dispatch(
+ "set_qty_done_pack", params={"package_id": package.id, "picking_id": -1}
+ )
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.stock_picking_not_found()
+ )
+
+ def test_set_qty_done_pack_picking_canceled(self):
+ move_lines = self.pack1_moves.mapped("move_line_ids")
+ package = move_lines.mapped("package_id")
+ self.picking.action_cancel()
+ response = self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package.id, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ message=self.service.msg_store.stock_picking_not_available(self.picking),
+ )
+
+ def test_set_qty_done_pack_package_not_found(self):
+ response = self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": -1, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.package_not_found(),
+ )
+
+ def test_set_qty_done_pack_multiple_product_ok(self):
+ move_lines = self.pack1_moves.mapped("move_line_ids")
+ package = move_lines.mapped("package_id")
+ self._test_set_qty_done_pack_ok(move_lines, package)
+
+ def test_set_qty_done_pack_one_product_ok(self):
+ move_lines = self.pack2_move.mapped("move_line_ids")
+ package = move_lines.mapped("package_id")
+ self._test_set_qty_done_pack_ok(move_lines, package)
+
+ def test_set_qty_done_pack_product_in_multiple_packages_ok(self):
+ move_lines = self.pack3_move.mapped("move_line_ids")
+ first_package = move_lines.mapped("package_id")[0]
+ self._test_set_qty_done_pack_ok(
+ move_lines,
+ # first_package done, not the second
+ first_package,
+ qties=[5, 0],
+ )
+
+ def test_set_qty_done_pack_picking_done(self):
+ pack1_move_lines = self.pack1_moves.mapped("move_line_ids")
+ package1 = pack1_move_lines.mapped("package_id")
+ pack2_move_lines = self.pack2_move.mapped("move_line_ids")
+ package2 = pack2_move_lines.mapped("package_id")
+ pack3_move_lines = self.pack3_move.mapped("move_line_ids")
+ packages3 = pack3_move_lines.mapped("package_id")
+ # process first package
+ self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package1.id, "picking_id": self.picking.id},
+ )
+ self.assertEqual(self.picking.state, "assigned")
+ # process second package
+ self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package2.id, "picking_id": self.picking.id},
+ )
+ self.assertEqual(self.picking.state, "assigned")
+ # process third package
+ self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": packages3[0].id, "picking_id": self.picking.id},
+ )
+ self.assertEqual(self.picking.state, "assigned")
+ # process last package
+ self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": packages3[1].id, "picking_id": self.picking.id},
+ )
+ # picking is done once all its moves have been processed
+ self.assertEqual(self.picking.state, "done")
From 8998779323867aee3e30e7ecd9ca28b94ad838ec Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 1 Jun 2020 13:25:45 +0200
Subject: [PATCH 248/940] delivery: implement 'set_qty_done_line' endpoint
---
shopfloor/services/delivery.py | 30 ++++++-
shopfloor/tests/__init__.py | 1 +
.../tests/test_delivery_set_qty_done_line.py | 89 +++++++++++++++++++
3 files changed, 117 insertions(+), 3 deletions(-)
create mode 100644 shopfloor/tests/test_delivery_set_qty_done_line.py
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 1bfacf1ac1..05a237bd21 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -1,4 +1,4 @@
-from odoo import fields
+from odoo import _, fields
from odoo.osv import expression
from odoo.addons.base_rest.components.service import to_int
@@ -320,7 +320,7 @@ def set_qty_done_pack(self, picking_id, package_id):
picking=picking, message=self.msg_store.package_not_found()
)
- def set_qty_done_line(self, picking_id, line_id):
+ def set_qty_done_line(self, picking_id, move_line_id):
"""Set a move line to "Done"
Should be called only for lines of raw products, /set_qty_done_pack
@@ -332,7 +332,31 @@ def set_qty_done_line(self, picking_id, line_id):
Transitions:
* deliver: always return here with updated data
"""
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id).exists()
+ if picking:
+ response = self._check_picking_status(picking)
+ if response:
+ return response
+ else:
+ return self._response_for_deliver(
+ message=self.msg_store.stock_picking_not_found()
+ )
+ line = self.env["stock.move.line"].browse(move_line_id).exists()
+ if line:
+ if line.package_id:
+ msg = {
+ "message_type": "warning",
+ "body": _(
+ "This line has a package, please select the package instead."
+ ),
+ }
+ return self._response_for_deliver(picking=picking, message=msg)
+ self._set_lines_done(line)
+ self._action_picking_done(picking)
+ return self._response_for_deliver(picking)
+ return self._response_for_deliver(
+ picking=picking, message=self.msg_store.record_not_found(),
+ )
def reset_qty_done_pack(self, picking_id, package_id):
"""Remove "Done" on a package
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 5a24485bdf..9ad84048c0 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -31,3 +31,4 @@
from . import test_delivery_base
from . import test_delivery_scan_deliver
from . import test_delivery_set_qty_done_pack
+from . import test_delivery_set_qty_done_line
diff --git a/shopfloor/tests/test_delivery_set_qty_done_line.py b/shopfloor/tests/test_delivery_set_qty_done_line.py
new file mode 100644
index 0000000000..6b07dc5a16
--- /dev/null
+++ b/shopfloor/tests/test_delivery_set_qty_done_line.py
@@ -0,0 +1,89 @@
+# Copyright 2020 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+from .test_delivery_base import DeliveryCommonCase
+
+
+class DeliverySetQtyDoneLineCase(DeliveryCommonCase):
+ """Tests for /set_qty_done_line"""
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.picking = picking = cls._create_picking(
+ lines=[
+ # put A in a package
+ (cls.product_a, 10),
+ # B as raw product
+ (cls.product_b, 10),
+ ]
+ )
+ cls.pack1_move = picking.move_lines[0]
+ cls.raw_move = picking.move_lines[1]
+ cls._fill_stock_for_moves(cls.pack1_move, in_package=True)
+ cls._fill_stock_for_moves(cls.raw_move)
+ picking.action_assign()
+
+ def _test_set_qty_done_line_ok(self, move_line):
+ response = self.service.dispatch(
+ "set_qty_done_line",
+ params={"move_line_id": move_line.id, "picking_id": self.picking.id},
+ )
+ self.assert_qty_done(move_line)
+ self.assert_response_deliver(response, picking=self.picking)
+
+ def test_set_qty_done_line_picking_not_found(self):
+ move_line = self.pack1_move.mapped("move_line_ids")
+ response = self.service.dispatch(
+ "set_qty_done_line", params={"move_line_id": move_line.id, "picking_id": -1}
+ )
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.stock_picking_not_found()
+ )
+
+ def test_set_qty_done_line_picking_canceled(self):
+ move_line = self.pack1_move.mapped("move_line_ids")
+ self.picking.action_cancel()
+ response = self.service.dispatch(
+ "set_qty_done_line",
+ params={"move_line_id": move_line.id, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ message=self.service.msg_store.stock_picking_not_available(self.picking),
+ )
+
+ def test_set_qty_done_line_line_not_found(self):
+ response = self.service.dispatch(
+ "set_qty_done_line",
+ params={"move_line_id": -1, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_set_qty_done_line_ok(self):
+ move_line = self.raw_move.mapped("move_line_ids")
+ self._test_set_qty_done_line_ok(move_line)
+ # picking is still assigned as only one move line have been processed
+ self.assertEqual(self.picking.state, "assigned")
+
+ def test_set_qty_done_line_picking_done(self):
+ # process the first move line with a package
+ move_line = self.pack1_move.mapped("move_line_ids")
+ package = move_line.mapped("package_id")
+ self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package.id, "picking_id": self.picking.id},
+ )
+ self.assertEqual(self.picking.state, "assigned")
+ # process the remaining move line
+ move_line = self.raw_move.mapped("move_line_ids")
+ self.service.dispatch(
+ "set_qty_done_line",
+ params={"move_line_id": move_line.id, "picking_id": self.picking.id},
+ )
+ # picking is done once all its moves have been processed
+ self.assertEqual(self.picking.state, "done")
From 36d1b2776d2323ed78bbd08b6b443030aa744da3 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 1 Jun 2020 14:54:36 +0200
Subject: [PATCH 249/940] delivery: implement 'list_stock_picking' endpoint
---
shopfloor/services/delivery.py | 53 ++++++++++---------
shopfloor/services/validator.py | 4 ++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_delivery_base.py | 2 +-
.../tests/test_delivery_list_stock_picking.py | 35 ++++++++++++
5 files changed, 68 insertions(+), 27 deletions(-)
create mode 100644 shopfloor/tests/test_delivery_list_stock_picking.py
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 05a237bd21..97fb9bc5ec 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -35,7 +35,7 @@ class Delivery(Component):
@property
def data_struct(self):
- return self.actions_for("data")
+ return self.actions_for("data_detail")
@property
def msg_store(self):
@@ -49,21 +49,25 @@ def _response_for_deliver(self, picking=None, message=None):
return self._response(
next_state="deliver",
data={
- "picking": self._data_for_stock_picking(picking) if picking else None
+ "picking": self.data_struct.picking_detail(picking) if picking else None
},
message=message,
)
- def _data_for_stock_picking(self, picking):
- data = self.data_struct.picking(picking)
- data.update(
- {
- "move_lines": [
- self.data_struct.move_line(ml) for ml in picking.move_line_ids
- ]
- }
+ def _response_for_manual_selection(self, pickings, message=None):
+ """Transition to the 'manual_selection' state
+
+ If no picking is passed, the screen shows an empty screen
+ """
+ return self._response(
+ next_state="manual_selection",
+ data={
+ "pickings": [
+ self.data_struct.picking_detail(picking) for picking in pickings
+ ],
+ },
+ message=message,
)
- return data
def _check_picking_status(self, picking):
"""Check if `picking` can be processed.
@@ -273,14 +277,21 @@ def _action_picking_done(self, picking):
return False
def list_stock_picking(self):
- """Return the list of stock pickings for the picking type
+ """Return the list of stock pickings for the picking types
It returns only stock picking available or partially available.
Transitions:
* manual_selection: next state to show the list of stock pickings
"""
- return self._response()
+ pickings = self.env["stock.picking"].search(self._pickings_domain(), order="id")
+ return self._response_for_manual_selection(pickings)
+
+ def _pickings_domain(self):
+ return [
+ ("picking_type_id", "in", self.picking_types.ids),
+ ("state", "not in", ["cancel", "done", "waiting", "draft"]),
+ ]
def select(self, picking_id):
"""Select a stock picking from its ID (found using /list_stock_picking)
@@ -462,24 +473,14 @@ def _states(self):
@property
def _schema_deliver(self):
- schema = self.schemas.picking()
- schema.update(
- {
- "move_lines": {
- "type": "list",
- "schema": {"type": "dict", "schema": self.schemas.move_line()},
- }
- }
- )
+ schema = self.schemas_detail.picking_detail()
return {"picking": {"type": "dict", "nullable": True, "schema": schema}}
@property
def _schema_selection_list(self):
+ schema = self.schemas_detail.picking_detail()
return {
- "pickings": {
- "type": "list",
- "schema": {"type": "dict", "schema": self.schemas.picking()},
- }
+ "pickings": {"type": "list", "schema": {"type": "dict", "schema": schema}}
}
def scan_deliver(self):
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
index c484c1514b..8bdf4d75cf 100644
--- a/shopfloor/services/validator.py
+++ b/shopfloor/services/validator.py
@@ -70,6 +70,10 @@ def _states(self):
def schemas(self):
return self.component(usage="schema")
+ @property
+ def schemas_detail(self):
+ return self.component(usage="schema_detail")
+
def _response_schema(self, data_schema=None, next_states=None):
"""Schema for the return validator
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 9ad84048c0..6e2a597fc7 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -32,3 +32,4 @@
from . import test_delivery_scan_deliver
from . import test_delivery_set_qty_done_pack
from . import test_delivery_set_qty_done_line
+from . import test_delivery_list_stock_picking
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
index 36f9225190..b0e6310de8 100644
--- a/shopfloor/tests/test_delivery_base.py
+++ b/shopfloor/tests/test_delivery_base.py
@@ -69,7 +69,7 @@ def setUp(self):
self.service = work.component(usage="delivery")
def _stock_picking_data(self, picking):
- return self.service._data_for_stock_picking(picking)
+ return self.service.data_struct.picking_detail(picking)
def assert_response_deliver(self, response, picking=None, message=None):
self.assert_response(
diff --git a/shopfloor/tests/test_delivery_list_stock_picking.py b/shopfloor/tests/test_delivery_list_stock_picking.py
new file mode 100644
index 0000000000..a44ac8b6d3
--- /dev/null
+++ b/shopfloor/tests/test_delivery_list_stock_picking.py
@@ -0,0 +1,35 @@
+# Copyright 2020 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+from .test_delivery_base import DeliveryCommonCase
+
+
+class DeliveryListStockPickingCase(DeliveryCommonCase):
+ """Tests for /list_stock_picking"""
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.picking1 = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10)]
+ )
+ cls.picking2 = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10)]
+ )
+
+ def assert_response_manual_selection(self, response, pickings=None, message=None):
+ self.assert_response(
+ response,
+ next_state="manual_selection",
+ data={
+ "pickings": [self._stock_picking_data(picking) for picking in pickings]
+ },
+ message=message,
+ )
+
+ def test_list_stock_picking_ok(self):
+ pickings = self.picking1 | self.picking2
+ response = self.service.dispatch("list_stock_picking", params={})
+ self.assert_response_manual_selection(
+ response, pickings=pickings,
+ )
From 7bc18b05750d6ecea386cfb078f2ef1ff51437b8 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Tue, 2 Jun 2020 09:46:16 +0200
Subject: [PATCH 250/940] delivery: implement 'select' endpoint
---
shopfloor/services/delivery.py | 10 ++++--
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_delivery_base.py | 10 ++++++
.../tests/test_delivery_list_stock_picking.py | 10 ------
shopfloor/tests/test_delivery_select.py | 33 +++++++++++++++++++
5 files changed, 52 insertions(+), 12 deletions(-)
create mode 100644 shopfloor/tests/test_delivery_select.py
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 97fb9bc5ec..93890db574 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -302,7 +302,13 @@ def select(self, picking_id):
* manual_selection: the selected stock picking is no longer valid
* deliver: with information about the stock.picking
"""
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id).exists()
+ if picking:
+ return self._response_for_deliver(picking)
+ response = self.list_stock_picking()
+ return self._response(
+ response, message=self.msg_store.stock_picking_not_found()
+ )
def set_qty_done_pack(self, picking_id, package_id):
"""Set a package to "Done"
@@ -490,7 +496,7 @@ def list_stock_picking(self):
return self._response_schema(next_states={"manual_selection"})
def select(self):
- return self._response_schema(next_states={"deliver"})
+ return self._response_schema(next_states={"deliver", "manual_selection"})
def set_qty_done_pack(self):
return self._response_schema(next_states={"deliver"})
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 6e2a597fc7..87bc146904 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -33,3 +33,4 @@
from . import test_delivery_set_qty_done_pack
from . import test_delivery_set_qty_done_line
from . import test_delivery_list_stock_picking
+from . import test_delivery_select
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
index b0e6310de8..1ab4a35fb7 100644
--- a/shopfloor/tests/test_delivery_base.py
+++ b/shopfloor/tests/test_delivery_base.py
@@ -79,6 +79,16 @@ def assert_response_deliver(self, response, picking=None, message=None):
message=message,
)
+ def assert_response_manual_selection(self, response, pickings=None, message=None):
+ self.assert_response(
+ response,
+ next_state="manual_selection",
+ data={
+ "pickings": [self._stock_picking_data(picking) for picking in pickings]
+ },
+ message=message,
+ )
+
def assert_qty_done(self, move_lines, qties=None):
"""Ensure that the quantities done are the expected ones.
diff --git a/shopfloor/tests/test_delivery_list_stock_picking.py b/shopfloor/tests/test_delivery_list_stock_picking.py
index a44ac8b6d3..545a4db435 100644
--- a/shopfloor/tests/test_delivery_list_stock_picking.py
+++ b/shopfloor/tests/test_delivery_list_stock_picking.py
@@ -17,16 +17,6 @@ def setUpClassBaseData(cls):
lines=[(cls.product_a, 10), (cls.product_b, 10)]
)
- def assert_response_manual_selection(self, response, pickings=None, message=None):
- self.assert_response(
- response,
- next_state="manual_selection",
- data={
- "pickings": [self._stock_picking_data(picking) for picking in pickings]
- },
- message=message,
- )
-
def test_list_stock_picking_ok(self):
pickings = self.picking1 | self.picking2
response = self.service.dispatch("list_stock_picking", params={})
diff --git a/shopfloor/tests/test_delivery_select.py b/shopfloor/tests/test_delivery_select.py
new file mode 100644
index 0000000000..14b214ef82
--- /dev/null
+++ b/shopfloor/tests/test_delivery_select.py
@@ -0,0 +1,33 @@
+# Copyright 2020 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+from .test_delivery_base import DeliveryCommonCase
+
+
+class DeliverySelectCase(DeliveryCommonCase):
+ """Tests for /select"""
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.picking1 = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10)]
+ )
+ cls.picking2 = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10)]
+ )
+
+ def test_select_ok(self):
+ response = self.service.dispatch(
+ "select", params={"picking_id": self.picking1.id}
+ )
+ self.assert_response_deliver(response, picking=self.picking1)
+
+ def test_select_not_found(self):
+ pickings = self.picking1 | self.picking2
+ response = self.service.dispatch("select", params={"picking_id": -1})
+ self.assert_response_manual_selection(
+ response,
+ pickings=pickings,
+ message=self.service.msg_store.stock_picking_not_found(),
+ )
From 5a088a16f655e3e1a8bd16abf1001fbf115cc132 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Tue, 2 Jun 2020 15:33:57 +0200
Subject: [PATCH 251/940] delivery: implement 'reset_qty_done_pack' endpoint
---
shopfloor/actions/message.py | 8 ++
shopfloor/services/delivery.py | 56 ++++++++--
.../test_delivery_reset_qty_done_pack.py | 105 ++++++++++++++++++
3 files changed, 161 insertions(+), 8 deletions(-)
create mode 100644 shopfloor/tests/test_delivery_reset_qty_done_pack.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index ddb9f7c712..fce3f88107 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -81,6 +81,14 @@ def package_not_found(self):
"body": _("This package does not exist anymore."),
}
+ def package_not_available_in_picking(self, package, picking):
+ return {
+ "message_type": "warning",
+ "body": _("Package {} is not available in transfer {}.").format(
+ package.name, picking.name
+ ),
+ }
+
def record_not_found(self):
return {
"message_type": "error",
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 93890db574..982383e16b 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -157,6 +157,12 @@ def _set_lines_done(self, lines):
# the qty_done is full
line.qty_done = line.product_uom_qty
+ def _reset_lines(self, lines):
+ for line in lines:
+ # note: the package level "is_done" field is automatically unset
+ # when the qty_done is not full
+ line.qty_done = 0
+
def _deliver_package(self, picking, package):
lines = package.move_line_ids
lines = lines.filtered(
@@ -173,20 +179,29 @@ def _deliver_package(self, picking, package):
new_picking = fields.first(lines.mapped("picking_id"))
return self._response_for_deliver(picking=new_picking)
- def _lines_base_domain(self):
- return [
+ def _lines_base_domain(self, no_qty_done=True):
+ domain = [
# we added auto_join for this, otherwise, the ORM would search all pickings
# in the picking type, and then use IN (ids)
("picking_id.picking_type_id", "in", self.picking_types.ids),
- ("qty_done", "=", 0),
]
+ if no_qty_done:
+ domain.append(("qty_done", "=", 0))
+ return domain
- def _lines_from_lot_domain(self, lot):
- return expression.AND([self._lines_base_domain(), [("lot_id", "=", lot.id)]])
+ def _lines_from_lot_domain(self, lot, no_qty_done=True):
+ return expression.AND(
+ [self._lines_base_domain(no_qty_done), [("lot_id", "=", lot.id)]]
+ )
+
+ def _lines_from_product_domain(self, product, no_qty_done=True):
+ return expression.AND(
+ [self._lines_base_domain(no_qty_done), [("product_id", "=", product.id)]]
+ )
- def _lines_from_product_domain(self, product):
+ def _lines_from_package_domain(self, package, no_qty_done=True):
return expression.AND(
- [self._lines_base_domain(), [("product_id", "=", product.id)]]
+ [self._lines_base_domain(no_qty_done), [("package_id", "=", package.id)]]
)
def _deliver_product(self, picking, product):
@@ -381,7 +396,32 @@ def reset_qty_done_pack(self, picking_id, package_id):
Transitions:
* deliver: always return here with updated data
"""
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id).exists()
+ if picking:
+ response = self._check_picking_status(picking)
+ if response:
+ return response
+ else:
+ return self._response_for_deliver(
+ message=self.msg_store.stock_picking_not_found()
+ )
+ package = self.env["stock.quant.package"].browse(package_id).exists()
+ if package:
+ lines = self.env["stock.move.line"].search(
+ self._lines_from_package_domain(package, no_qty_done=False)
+ )
+ if not lines:
+ return self._response_for_deliver(
+ picking,
+ message=self.msg_store.package_not_available_in_picking(
+ package, picking
+ ),
+ )
+ self._reset_lines(lines)
+ return self._response_for_deliver(picking)
+ return self._response_for_deliver(
+ picking=picking, message=self.msg_store.package_not_found()
+ )
def reset_qty_done_line(self, picking_id, line_id):
"""Remove "Done" on a move line
diff --git a/shopfloor/tests/test_delivery_reset_qty_done_pack.py b/shopfloor/tests/test_delivery_reset_qty_done_pack.py
new file mode 100644
index 0000000000..99048b6d5c
--- /dev/null
+++ b/shopfloor/tests/test_delivery_reset_qty_done_pack.py
@@ -0,0 +1,105 @@
+# Copyright 2020 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+from .test_delivery_base import DeliveryCommonCase
+
+
+class DeliveryResetQtyDonePackCase(DeliveryCommonCase):
+ """Tests for /reset_qty_done_pack"""
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.picking = picking = cls._create_picking(
+ lines=[
+ # we'll put A and B in a single package
+ (cls.product_a, 10),
+ (cls.product_b, 10),
+ # C alone in a package
+ (cls.product_c, 10),
+ ]
+ )
+ cls.pack1_moves = picking.move_lines[:2]
+ cls.pack2_move = picking.move_lines[2]
+ cls._fill_stock_for_moves(cls.pack1_moves, in_package=True)
+ cls._fill_stock_for_moves(cls.pack2_move, in_package=True)
+ picking.action_assign()
+ # Some records not related at all to the processed picking
+ cls.free_package = cls.env["stock.quant.package"].create(
+ {"name": "FREE_PACKAGE"}
+ )
+
+ def test_reset_qty_done_pack_picking_not_found(self):
+ move_lines = self.pack1_moves.mapped("move_line_ids")
+ package = move_lines.mapped("package_id")
+ response = self.service.dispatch(
+ "reset_qty_done_pack", params={"package_id": package.id, "picking_id": -1}
+ )
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.stock_picking_not_found()
+ )
+
+ def test_reset_qty_done_pack_package_not_found(self):
+ response = self.service.dispatch(
+ "reset_qty_done_pack",
+ params={"package_id": -1, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.package_not_found(),
+ )
+
+ def test_reset_qty_done_pack_package_not_available_in_picking(self):
+ response = self.service.dispatch(
+ "reset_qty_done_pack",
+ params={"package_id": self.free_package.id, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.package_not_available_in_picking(
+ self.free_package, self.picking
+ ),
+ )
+
+ def test_reset_qty_done_pack_ok(self):
+ move_lines = self.pack1_moves.mapped("move_line_ids")
+ package = move_lines.mapped("package_id")
+ # Set qty done on a package, related move lines are "done"
+ self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package.id, "picking_id": self.picking.id},
+ )
+ self.assertTrue(all(ml.qty_done == ml.product_uom_qty for ml in move_lines))
+ # Reset it, no related move lines are "done"
+ response = self.service.dispatch(
+ "reset_qty_done_pack",
+ params={"package_id": package.id, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(response, picking=self.picking)
+ self.assertFalse(any(ml.qty_done > 0 for ml in move_lines))
+
+ def test_reset_qty_done_pack_picking_status(self):
+ package1 = self.pack1_moves.mapped("move_line_ids").mapped("package_id")
+ package2 = self.pack2_move.mapped("move_line_ids").mapped("package_id")
+ # Set qty done for all lines (all linked to packages here), picking is
+ # automatically set to done
+ self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package1.id, "picking_id": self.picking.id},
+ )
+ self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package2.id, "picking_id": self.picking.id},
+ )
+ self.assertEqual(self.picking.state, "done")
+ # Try to reset one of them => picking already processed
+ response = self.service.dispatch(
+ "reset_qty_done_pack",
+ params={"package_id": package1.id, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.already_done()
+ )
+ self.assertEqual(self.picking.state, "done")
From b71b4f8cd4375c81eb62cdc37d31f89ee07aa1fd Mon Sep 17 00:00:00 2001
From: sebalix
Date: Tue, 2 Jun 2020 18:09:57 +0200
Subject: [PATCH 252/940] delivery: implement 'reset_qty_done_line' endpoint
---
shopfloor/actions/message.py | 14 +++
shopfloor/services/delivery.py | 42 +++++--
.../test_delivery_reset_qty_done_line.py | 117 ++++++++++++++++++
3 files changed, 163 insertions(+), 10 deletions(-)
create mode 100644 shopfloor/tests/test_delivery_reset_qty_done_line.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index fce3f88107..ae2e1fafd7 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -89,6 +89,14 @@ def package_not_available_in_picking(self, package, picking):
),
}
+ def line_not_available_in_picking(self, picking):
+ return {
+ "message_type": "warning",
+ "body": _("This line is not available in transfer {}.").format(
+ picking.name
+ ),
+ }
+
def record_not_found(self):
return {
"message_type": "error",
@@ -219,6 +227,12 @@ def stock_picking_not_available(self, picking):
"body": _("Transfer {} is not available.").format(picking.name),
}
+ def line_has_package_scan_package(self):
+ return {
+ "message_type": "warning",
+ "body": _("This line has a package, please select the package instead."),
+ }
+
def product_multiple_packages_scan_package(self):
return {
"message_type": "warning",
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 982383e16b..9073010009 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -1,4 +1,4 @@
-from odoo import _, fields
+from odoo import fields
from odoo.osv import expression
from odoo.addons.base_rest.components.service import to_int
@@ -376,13 +376,10 @@ def set_qty_done_line(self, picking_id, move_line_id):
line = self.env["stock.move.line"].browse(move_line_id).exists()
if line:
if line.package_id:
- msg = {
- "message_type": "warning",
- "body": _(
- "This line has a package, please select the package instead."
- ),
- }
- return self._response_for_deliver(picking=picking, message=msg)
+ return self._response_for_deliver(
+ picking=picking,
+ message=self.msg_store.line_has_package_scan_package(),
+ )
self._set_lines_done(line)
self._action_picking_done(picking)
return self._response_for_deliver(picking)
@@ -423,7 +420,7 @@ def reset_qty_done_pack(self, picking_id, package_id):
picking=picking, message=self.msg_store.package_not_found()
)
- def reset_qty_done_line(self, picking_id, line_id):
+ def reset_qty_done_line(self, picking_id, move_line_id):
"""Remove "Done" on a move line
Should be called only for lines of raw products, /set_qty_done_pack
@@ -432,7 +429,32 @@ def reset_qty_done_line(self, picking_id, line_id):
Transitions:
* deliver: always return here with updated data
"""
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id).exists()
+ if picking:
+ response = self._check_picking_status(picking)
+ if response:
+ return response
+ else:
+ return self._response_for_deliver(
+ message=self.msg_store.stock_picking_not_found()
+ )
+ line = self.env["stock.move.line"].browse(move_line_id).exists()
+ if line:
+ if line.picking_id != picking:
+ return self._response_for_deliver(
+ picking=picking,
+ message=self.msg_store.line_not_available_in_picking(picking),
+ )
+ if line.package_id:
+ return self._response_for_deliver(
+ picking=picking,
+ message=self.msg_store.line_has_package_scan_package(),
+ )
+ self._reset_lines(line)
+ return self._response_for_deliver(picking)
+ return self._response_for_deliver(
+ picking=picking, message=self.msg_store.record_not_found(),
+ )
def done(self, picking_id):
"""Set the stock picking to done
diff --git a/shopfloor/tests/test_delivery_reset_qty_done_line.py b/shopfloor/tests/test_delivery_reset_qty_done_line.py
new file mode 100644
index 0000000000..ca627ec9e1
--- /dev/null
+++ b/shopfloor/tests/test_delivery_reset_qty_done_line.py
@@ -0,0 +1,117 @@
+# Copyright 2020 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+from .test_delivery_base import DeliveryCommonCase
+
+
+class DeliveryResetQtyDoneLineCase(DeliveryCommonCase):
+ """Tests for /reset_qty_done_line"""
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.picking = picking = cls._create_picking(
+ lines=[
+ # we'll put A and B in a single package
+ (cls.product_a, 10),
+ (cls.product_b, 10),
+ # C as raw product
+ (cls.product_c, 10),
+ ]
+ )
+ cls.pack1_moves = picking.move_lines[:2]
+ cls.raw_move = picking.move_lines[2]
+ cls._fill_stock_for_moves(cls.pack1_moves, in_package=True)
+ cls._fill_stock_for_moves(cls.raw_move)
+ picking.action_assign()
+ # Some records not related at all to the processed picking
+ cls.free_picking = cls._create_picking(lines=[(cls.product_d, 10)])
+ cls.free_raw_move = cls.free_picking.move_lines[0]
+ cls._fill_stock_for_moves(cls.free_raw_move)
+ cls.free_picking.action_assign()
+
+ def test_reset_qty_done_line_picking_not_found(self):
+ move_lines = self.pack1_moves.mapped("move_line_ids")
+ response = self.service.dispatch(
+ "reset_qty_done_line",
+ params={"move_line_id": move_lines[0].id, "picking_id": -1},
+ )
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.stock_picking_not_found()
+ )
+
+ def test_reset_qty_done_line_line_not_found(self):
+ response = self.service.dispatch(
+ "reset_qty_done_line",
+ params={"move_line_id": -1, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_reset_qty_done_line_line_not_available_in_picking(self):
+ move_line = self.free_raw_move.mapped("move_line_ids")
+ response = self.service.dispatch(
+ "reset_qty_done_line",
+ params={"move_line_id": move_line.id, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.line_not_available_in_picking(self.picking),
+ )
+
+ def test_reset_qty_done_line_ok(self):
+ move_line = self.raw_move.mapped("move_line_ids")
+ # Set qty done on a line
+ self.service.dispatch(
+ "set_qty_done_line",
+ params={"move_line_id": move_line.id, "picking_id": self.picking.id},
+ )
+ self.assertTrue(move_line.qty_done == move_line.product_uom_qty)
+ # Reset it, no related move lines are "done"
+ response = self.service.dispatch(
+ "reset_qty_done_line",
+ params={"move_line_id": move_line.id, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(response, picking=self.picking)
+ self.assertFalse(move_line.qty_done)
+
+ def test_reset_qty_done_line_with_package(self):
+ move_line = self.pack1_moves[0].mapped("move_line_ids")
+ response = self.service.dispatch(
+ "set_qty_done_line",
+ params={"move_line_id": move_line.id, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.line_has_package_scan_package(),
+ )
+
+ def test_reset_qty_done_pack_picking_status(self):
+ move_lines = self.picking.move_line_ids
+ raw_move_line = self.raw_move.mapped("move_line_ids")
+ # Set qty done for all lines (some are linked to packages here),
+ # picking is automatically set to done
+ for package in move_lines.mapped("package_id"):
+ self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package.id, "picking_id": self.picking.id},
+ )
+ self.service.dispatch(
+ "set_qty_done_line",
+ params={"move_line_id": raw_move_line.id, "picking_id": self.picking.id},
+ )
+ self.assertEqual(self.picking.state, "done")
+ # Try to reset one of them => picking already processed
+ response = self.service.dispatch(
+ "reset_qty_done_line",
+ params={"move_line_id": raw_move_line.id, "picking_id": self.picking.id},
+ )
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.already_done()
+ )
+ self.assertEqual(self.picking.state, "done")
From 84b381b4221ad27a303f0ab44139dcc239c4bc3e Mon Sep 17 00:00:00 2001
From: sebalix
Date: Wed, 3 Jun 2020 11:45:52 +0200
Subject: [PATCH 253/940] delivery: implement 'done' endpoint
---
shopfloor/actions/message.py | 23 ++++++
shopfloor/services/delivery.py | 55 +++++++++++++-
shopfloor/tests/test_delivery_done.py | 104 ++++++++++++++++++++++++++
3 files changed, 178 insertions(+), 4 deletions(-)
create mode 100644 shopfloor/tests/test_delivery_done.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index ae2e1fafd7..143bb83e7b 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -292,3 +292,26 @@ def batch_transfer_line_done(self):
"message_type": "success",
"body": _("Batch Transfer line done"),
}
+
+ def transfer_complete(self, picking):
+ return {
+ "message_type": "success",
+ "body": _("Transfer {} complete").format(picking.name),
+ }
+
+ def transfer_confirm_done(self):
+ return {
+ "message_type": "warning",
+ "body": _(
+ "Not all lines have been processed, do you want to "
+ "confirm partial operation ?"
+ ),
+ }
+
+ def transfer_no_qty_done(self):
+ return {
+ "message_type": "warning",
+ "body": _(
+ "No quantity has been processed, unable to complete the transfer."
+ ),
+ }
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 9073010009..ba1f6293ac 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -1,7 +1,8 @@
from odoo import fields
from odoo.osv import expression
+from odoo.tools.float_utils import float_is_zero
-from odoo.addons.base_rest.components.service import to_int
+from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
@@ -69,6 +70,16 @@ def _response_for_manual_selection(self, pickings, message=None):
message=message,
)
+ def _response_for_confirm_done(self, picking, message=None):
+ """Transition to the 'confirm_done' state."""
+ return self._response(
+ next_state="confirm_done",
+ data={
+ "picking": self.data_struct.picking_detail(picking) if picking else None
+ },
+ message=message,
+ )
+
def _check_picking_status(self, picking):
"""Check if `picking` can be processed.
@@ -456,14 +467,47 @@ def reset_qty_done_line(self, picking_id, move_line_id):
picking=picking, message=self.msg_store.record_not_found(),
)
- def done(self, picking_id):
+ def done(self, picking_id, confirm=False):
"""Set the stock picking to done
Transitions:
* deliver: error during action
* confirm_done: when not all lines of the stock.picking are done
"""
- return self._response()
+ picking = self.env["stock.picking"].browse(picking_id).exists()
+ if picking:
+ response = self._check_picking_status(picking)
+ if response:
+ return response
+ else:
+ return self._response_for_deliver(
+ message=self.msg_store.stock_picking_not_found()
+ )
+ if self._action_picking_done(picking):
+ return self._response_for_deliver(
+ message=self.msg_store.transfer_complete(picking)
+ )
+ if confirm:
+ precision_digits = self.env["decimal.precision"].precision_get(
+ "Product Unit of Measure"
+ )
+ no_quantities_done = all(
+ float_is_zero(move_line.qty_done, precision_digits=precision_digits)
+ for move_line in picking.move_line_ids.filtered(
+ lambda m: m.state not in ("done", "cancel")
+ )
+ )
+ if no_quantities_done:
+ return self._response_for_deliver(
+ message=self.msg_store.transfer_no_qty_done()
+ )
+ picking.action_done()
+ return self._response_for_deliver(
+ message=self.msg_store.transfer_complete(picking)
+ )
+ return self._response_for_confirm_done(
+ picking, message=self.msg_store.transfer_confirm_done(),
+ )
class ShopfloorDeliveryValidator(Component):
@@ -515,7 +559,10 @@ def reset_qty_done_line(self):
}
def done(self):
- return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}}
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "confirm": {"coerce": to_bool, "required": False, "type": "boolean"},
+ }
class ShopfloorDeliveryValidatorResponse(Component):
diff --git a/shopfloor/tests/test_delivery_done.py b/shopfloor/tests/test_delivery_done.py
new file mode 100644
index 0000000000..e2ef593719
--- /dev/null
+++ b/shopfloor/tests/test_delivery_done.py
@@ -0,0 +1,104 @@
+# Copyright 2020 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+from .test_delivery_base import DeliveryCommonCase
+
+
+class DeliveryDoneCase(DeliveryCommonCase):
+ """Tests for /done"""
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.picking = picking = cls._create_picking(
+ lines=[
+ # we'll put A and B in a single package
+ (cls.product_a, 10),
+ (cls.product_b, 10),
+ # C as raw product
+ (cls.product_c, 10),
+ ]
+ )
+ cls.pack1_moves = picking.move_lines[:2]
+ cls.raw_move = cls.picking.move_lines[2]
+ cls._fill_stock_for_moves(cls.pack1_moves, in_package=True)
+ cls._fill_stock_for_moves(cls.raw_move)
+ cls.picking.action_assign()
+
+ def assert_response_confirm_done(self, response, picking=None, message=None):
+ self.assert_response(
+ response,
+ next_state="confirm_done",
+ data={"picking": self._stock_picking_data(picking) if picking else None},
+ message=message,
+ )
+
+ def test_done_picking_not_found(self):
+ response = self.service.dispatch("done", params={"picking_id": -1})
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.stock_picking_not_found()
+ )
+
+ def test_done_all_qty_done(self):
+ # Do not use the /set_qty_done_line endpoint to set done qties to not
+ # update the picking to 'done' state automatically
+ for move_line in self.picking.move_line_ids:
+ move_line.qty_done = move_line.product_uom_qty
+ response = self.service.dispatch("done", params={"picking_id": self.picking.id})
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.transfer_complete(self.picking),
+ )
+ self.assertEqual(self.picking.state, "done")
+
+ def test_done_no_qty_done(self):
+ response = self.service.dispatch("done", params={"picking_id": self.picking.id})
+ self.assert_response_confirm_done(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.transfer_confirm_done(),
+ )
+ self.assertEqual(self.picking.state, "assigned")
+
+ def test_done_some_qty_done(self):
+ move_line = self.raw_move.move_line_ids[0]
+ self.service.dispatch(
+ "set_qty_done_line",
+ params={"move_line_id": move_line.id, "picking_id": self.picking.id},
+ )
+ response = self.service.dispatch("done", params={"picking_id": self.picking.id})
+ self.assert_response_confirm_done(
+ response,
+ picking=self.picking,
+ message=self.service.msg_store.transfer_confirm_done(),
+ )
+ self.assertEqual(self.picking.state, "assigned")
+
+ def test_done_no_qty_done_confirm(self):
+ self.assertEqual(self.picking.state, "assigned")
+ response = self.service.dispatch(
+ "done", params={"picking_id": self.picking.id, "confirm": True}
+ )
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.transfer_no_qty_done(),
+ )
+ self.assertEqual(self.picking.state, "assigned")
+
+ def test_done_some_qty_done_confirm(self):
+ move_line = self.raw_move.move_line_ids[0]
+ self.service.dispatch(
+ "set_qty_done_line",
+ params={"move_line_id": move_line.id, "picking_id": self.picking.id},
+ )
+ self.assertEqual(self.picking.state, "assigned")
+ response = self.service.dispatch(
+ "done", params={"picking_id": self.picking.id, "confirm": True}
+ )
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.transfer_complete(self.picking),
+ )
+ self.assertEqual(self.picking.state, "done")
+ self.assertEqual(self.picking.move_lines, self.raw_move)
+ backorder = self.picking.backorder_ids
+ self.assertTrue(backorder)
+ self.assertEqual(backorder.state, "assigned")
+ self.assertEqual(self.pack1_moves.picking_id, backorder)
From 1e4dd9005b1d2b3e3ee537e070d5b3cbe6faacb7 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Thu, 4 Jun 2020 18:16:44 +0200
Subject: [PATCH 254/940] delivery: try to update transfer to done each time we
update lines
---
shopfloor/services/delivery.py | 29 ++++++++++--
shopfloor/tests/test_delivery_scan_deliver.py | 47 +++++++++++++++++++
2 files changed, 71 insertions(+), 5 deletions(-)
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index ba1f6293ac..420a811bf2 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -163,10 +163,18 @@ def scan_deliver(self, barcode, picking_id=None):
)
def _set_lines_done(self, lines):
+ """Set done quantities on `lines`.
+
+ Once all lines of a picking have been processed, the picking will be
+ validated automatically.
+ Return `True` if the related picking has been validated.
+ """
for line in lines:
# note: the package level is automatically set to "is_done" when
# the qty_done is full
line.qty_done = line.product_uom_qty
+ picking = fields.first(lines.mapped("picking_id"))
+ return self._action_picking_done(picking)
def _reset_lines(self, lines):
for line in lines:
@@ -186,8 +194,11 @@ def _deliver_package(self, picking, package):
message=self.msg_store.cannot_move_something_in_picking_type(),
)
# TODO add a message if any of the lines already had a qty_done > 0
- self._set_lines_done(lines)
new_picking = fields.first(lines.mapped("picking_id"))
+ if self._set_lines_done(lines):
+ return self._response_for_deliver(
+ message=self.msg_store.transfer_complete(new_picking)
+ )
return self._response_for_deliver(picking=new_picking)
def _lines_base_domain(self, no_qty_done=True):
@@ -252,7 +263,10 @@ def _deliver_product(self, picking, product):
new_picking,
message=self.msg_store.product_mixed_package_scan_package(),
)
- self._set_lines_done(lines)
+ if self._set_lines_done(lines):
+ return self._response_for_deliver(
+ message=self.msg_store.transfer_complete(new_picking)
+ )
return self._response_for_deliver(new_picking)
def _deliver_lot(self, picking, lot):
@@ -286,7 +300,10 @@ def _deliver_lot(self, picking, lot):
message=self.msg_store.lot_mixed_package_scan_package(),
)
- self._set_lines_done(lines)
+ if self._set_lines_done(lines):
+ return self._response_for_deliver(
+ message=self.msg_store.transfer_complete(new_picking)
+ )
return self._response_for_deliver(new_picking)
def _action_picking_done(self, picking):
@@ -391,8 +408,10 @@ def set_qty_done_line(self, picking_id, move_line_id):
picking=picking,
message=self.msg_store.line_has_package_scan_package(),
)
- self._set_lines_done(line)
- self._action_picking_done(picking)
+ if self._set_lines_done(line):
+ return self._response_for_deliver(
+ message=self.msg_store.transfer_complete(picking)
+ )
return self._response_for_deliver(picking)
return self._response_for_deliver(
picking=picking, message=self.msg_store.record_not_found(),
diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py
index e75e285201..264510337b 100644
--- a/shopfloor/tests/test_delivery_scan_deliver.py
+++ b/shopfloor/tests/test_delivery_scan_deliver.py
@@ -102,7 +102,9 @@ def _test_scan_set_done_ok(self, move_lines, barcode):
def test_scan_deliver_scan_package_ok(self):
move_lines = self.pack1_moves.mapped("move_line_ids")
package = move_lines.mapped("package_id")
+ self.assertEqual(self.picking.state, "assigned")
self._test_scan_set_done_ok(move_lines, package.name)
+ self.assertEqual(self.picking.state, "assigned")
def test_scan_deliver_scan_package_no_move_lines(self):
response = self.service.dispatch(
@@ -187,6 +189,51 @@ def test_scan_deliver_scan_lot_in_mixed_package(self):
message=self.service.msg_store.lot_mixed_package_scan_package(),
)
+ 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
+ package1 = self.pack1_moves.mapped("move_line_ids").mapped("package_id")
+ package2 = self.pack2_move.mapped("move_line_ids").mapped("package_id")
+ self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package1.id, "picking_id": self.picking.id},
+ )
+ self.assertEqual(self.picking.state, "assigned")
+ self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package2.id, "picking_id": self.picking.id},
+ )
+ self.assertEqual(self.picking.state, "assigned")
+ self.service.dispatch(
+ "scan_deliver",
+ params={
+ "barcode": self.raw_move.product_id.barcode,
+ "picking_id": self.picking.id,
+ },
+ )
+ self.assertEqual(self.picking.state, "assigned")
+ lot = self.raw_lot_move.move_line_ids.lot_id
+ response = self.service.dispatch(
+ "scan_deliver", params={"barcode": lot.name, "picking_id": self.picking.id},
+ )
+ self.assertEqual(self.picking.state, "assigned")
+ packages_f = self.pack3_move.move_line_ids.mapped("package_id")
+ # While all lines are not processed, response still returns the picking
+ self.assert_response_deliver(
+ response, picking=self.picking,
+ )
+ response = None
+ # Once all lines are processed, the last response has no picking returned
+ for package in packages_f:
+ response = self.service.dispatch(
+ "set_qty_done_pack",
+ params={"package_id": package.id, "picking_id": self.picking.id},
+ )
+ self.assertEqual(self.picking.state, "done")
+ self.assert_response_deliver(
+ response, message=self.service.msg_store.transfer_complete(self.picking),
+ )
+
class DeliveryScanDeliverSpecialCase(DeliveryCommonCase):
"""Special cases with different setup for /scan_deliver"""
From fc005a29902d77a28b7789288be829dbc782c699 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Fri, 5 Jun 2020 10:11:32 +0200
Subject: [PATCH 255/940] backend: new service properties 'data', 'data_detail'
and 'msg_store'
+ update all existing services to use them
---
shopfloor/services/checkout.py | 232 ++++++++++-----------
shopfloor/services/cluster_picking.py | 38 ++--
shopfloor/services/delivery.py | 14 +-
shopfloor/services/scan_anything.py | 16 +-
shopfloor/services/service.py | 12 ++
shopfloor/services/single_pack_putaway.py | 52 +++--
shopfloor/services/single_pack_transfer.py | 21 +-
shopfloor/tests/test_delivery_base.py | 2 +-
8 files changed, 176 insertions(+), 211 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index fab2f37847..d73ee56489 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -34,6 +34,90 @@ class Checkout(Component):
_usage = "checkout"
_description = __doc__
+ def _response_for_select_line(self, picking, message=None):
+ if all(line.shopfloor_checkout_done for line in picking.move_line_ids):
+ return self._response_for_summary(picking, message=message)
+ return self._response(
+ next_state="select_line",
+ data={"picking": self._data_for_stock_picking(picking)},
+ message=message,
+ )
+
+ def _response_for_summary(self, picking, need_confirm=False, message=None):
+ return self._response(
+ next_state="summary" if not need_confirm else "confirm_done",
+ data={
+ "picking": self._data_for_stock_picking(picking, done=True),
+ "all_processed": not bool(self._lines_to_pack(picking)),
+ },
+ message=message,
+ )
+
+ def _response_for_select_document(self, message=None):
+ return self._response(next_state="select_document", message=message)
+
+ def _response_for_manual_selection(self, message=None):
+ pickings = self.env["stock.picking"].search(
+ self._domain_for_list_stock_picking(),
+ order=self._order_for_list_stock_picking(),
+ )
+ data = {"pickings": self.data.pickings(pickings)}
+ return self._response(next_state="manual_selection", data=data, message=message)
+
+ def _response_for_select_package(self, lines, message=None):
+ picking = lines.mapped("picking_id")
+ return self._response(
+ next_state="select_package",
+ data={
+ "selected_move_lines": self._data_for_move_lines(lines.sorted()),
+ "picking": self.data.picking(picking),
+ },
+ message=message,
+ )
+
+ def _response_for_select_dest_package(self, picking, move_lines, message=None):
+ packages = picking.mapped("move_line_ids.package_id") | picking.mapped(
+ "move_line_ids.result_package_id"
+ )
+ if not packages:
+ return self._response_for_select_package(
+ move_lines,
+ message={
+ "message_type": "warning",
+ "body": _("No valid package to select."),
+ },
+ )
+ picking_data = self.data.picking(picking)
+ packages_data = self.data.packages(
+ packages.sorted(), picking=picking, with_packaging=True
+ )
+ return self._response(
+ next_state="select_dest_package",
+ data={
+ "picking": picking_data,
+ "packages": packages_data,
+ "selected_move_lines": self._data_for_move_lines(move_lines.sorted()),
+ },
+ message=message,
+ )
+
+ def _response_for_change_packaging(self, picking, package, packaging_list):
+ if not package:
+ return self._response_for_summary(
+ picking, message=self.msg_store.record_not_found()
+ )
+
+ return self._response(
+ next_state="change_packaging",
+ data={
+ "picking": self.data.picking(picking),
+ "package": self.data.package(
+ package, picking=picking, with_packaging=True
+ ),
+ "packaging": self.data.packaging_list(packaging_list.sorted()),
+ },
+ )
+
def scan_document(self, barcode):
"""Scan a package, a stock.picking or a location
@@ -57,7 +141,6 @@ def scan_document(self, barcode):
destination pack set
"""
search = self.actions_for("search")
- message = self.actions_for("message")
picking = search.picking_from_scan(barcode)
if not picking:
location = search.location_from_scan(barcode)
@@ -66,7 +149,7 @@ def scan_document(self, barcode):
self.picking_types.mapped("default_location_src_id")
):
return self._response_for_select_document(
- message=message.location_not_allowed()
+ message=self.msg_store.location_not_allowed()
)
lines = location.source_move_line_ids
pickings = lines.mapped("picking_id")
@@ -103,62 +186,37 @@ def scan_document(self, barcode):
return self._select_picking(picking, "select_document")
def _select_picking(self, picking, state_for_error):
- message = self.actions_for("message")
if not picking:
if state_for_error == "manual_selection":
return self._response_for_manual_selection(
- message=message.stock_picking_not_found()
+ message=self.msg_store.stock_picking_not_found()
)
return self._response_for_select_document(
- message=message.barcode_not_found()
+ message=self.msg_store.barcode_not_found()
)
if picking.picking_type_id not in self.picking_types:
if state_for_error == "manual_selection":
return self._response_for_manual_selection(
- message=message.cannot_move_something_in_picking_type()
+ message=self.msg_store.cannot_move_something_in_picking_type()
)
return self._response_for_select_document(
- message=message.cannot_move_something_in_picking_type()
+ message=self.msg_store.cannot_move_something_in_picking_type()
)
if picking.state != "assigned":
if state_for_error == "manual_selection":
return self._response_for_manual_selection(
- message=message.stock_picking_not_available(picking)
+ message=self.msg_store.stock_picking_not_available(picking)
)
return self._response_for_select_document(
- message=message.stock_picking_not_available(picking)
+ message=self.msg_store.stock_picking_not_available(picking)
)
return self._response_for_select_line(picking)
- def _response_for_select_line(self, picking, message=None):
- if all(line.shopfloor_checkout_done for line in picking.move_line_ids):
- return self._response_for_summary(picking, message=message)
- return self._response(
- next_state="select_line",
- data={"picking": self._data_for_stock_picking(picking)},
- message=message,
- )
-
- def _response_for_summary(self, picking, need_confirm=False, message=None):
- return self._response(
- next_state="summary" if not need_confirm else "confirm_done",
- data={
- "picking": self._data_for_stock_picking(picking, done=True),
- "all_processed": not bool(self._lines_to_pack(picking)),
- },
- message=message,
- )
-
- def _response_for_select_document(self, message=None):
- return self._response(next_state="select_document", message=message)
-
def _data_for_move_lines(self, lines, **kw):
- data_struct = self.actions_for("data")
- return data_struct.move_lines(lines, **kw)
+ return self.data.move_lines(lines, **kw)
def _data_for_stock_picking(self, picking, done=False):
- data_struct = self.actions_for("data")
- data = data_struct.picking(picking)
+ data = self.data.picking(picking)
line_picker = self._lines_checkout_done if done else self._lines_to_pack
data.update(
{
@@ -195,15 +253,6 @@ def list_stock_picking(self):
"""
return self._response_for_manual_selection()
- def _response_for_manual_selection(self, message=None):
- pickings = self.env["stock.picking"].search(
- self._domain_for_list_stock_picking(),
- order=self._order_for_list_stock_picking(),
- )
- data_struct = self.actions_for("data")
- data = {"pickings": data_struct.pickings(pickings)}
- return self._response(next_state="manual_selection", data=data, message=message)
-
def select(self, picking_id):
"""Select a stock picking for the scenario
@@ -225,18 +274,6 @@ def select(self, picking_id):
picking = self.env["stock.picking"].browse(picking_id).exists()
return self._select_picking(picking, "manual_selection")
- def _response_for_select_package(self, lines, message=None):
- data_struct = self.actions_for("data")
- picking = lines.mapped("picking_id")
- return self._response(
- next_state="select_package",
- data={
- "selected_move_lines": self._data_for_move_lines(lines.sorted()),
- "picking": data_struct.picking(picking),
- },
- message=message,
- )
-
def _select_lines(self, lines):
for line in lines:
if line.shopfloor_checkout_done:
@@ -274,7 +311,6 @@ def scan_line(self, picking_id, barcode):
return self._response_stock_picking_does_not_exist()
search = self.actions_for("search")
- message = self.actions_for("message")
selection_lines = self._lines_to_pack(picking)
if not selection_lines:
@@ -293,7 +329,7 @@ def scan_line(self, picking_id, barcode):
return self._select_lines_from_lot(picking, selection_lines, lot)
return self._response_for_select_line(
- picking, message=message.barcode_not_found()
+ picking, message=self.msg_store.barcode_not_found()
)
def _select_lines_from_package(self, picking, selection_lines, package):
@@ -312,10 +348,9 @@ def _select_lines_from_package(self, picking, selection_lines, package):
return self._response_for_select_package(lines)
def _select_lines_from_product(self, picking, selection_lines, product):
- message = self.actions_for("message")
if product.tracking in ("lot", "serial"):
return self._response_for_select_line(
- picking, message=message.scan_lot_on_product_tracked_by_lot()
+ picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot()
)
lines = selection_lines.filtered(lambda l: l.product_id == product)
@@ -339,7 +374,7 @@ def _select_lines_from_product(self, picking, selection_lines, product):
# package.
if packages and len({l.package_id for l in lines}) > 1:
return self._response_for_select_line(
- picking, message=message.product_multiple_packages_scan_package()
+ picking, message=self.msg_store.product_multiple_packages_scan_package()
)
elif packages:
# Select all the lines of the package when we scan a product in a
@@ -360,7 +395,6 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
},
)
- message = self.actions_for("message")
# When lots are as units outside of packages, we can select them for
# packing, but if they are in a package, we want the user to scan the packages.
# If the product is only in one package though, scanning the lot selects
@@ -372,7 +406,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
# package.
if packages and len({l.package_id for l in lines}) > 1:
return self._response_for_select_line(
- picking, message=message.lot_multiple_packages_scan_package()
+ picking, message=self.msg_store.lot_multiple_packages_scan_package()
)
elif packages:
# Select all the lines of the package when we scan a lot in a
@@ -384,17 +418,15 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
def _select_line_package(self, picking, selection_lines, package):
if not package:
- message = self.actions_for("message")
return self._response_for_select_line(
- picking, message=message.record_not_found()
+ picking, message=self.msg_store.record_not_found()
)
return self._select_lines_from_package(picking, selection_lines, package)
def _select_line_move_line(self, picking, selection_lines, move_line):
if not move_line:
- message = self.actions_for("message")
return self._response_for_select_line(
- picking, message=message.record_not_found()
+ picking, message=self.msg_store.record_not_found()
)
# normally, the client should sent only move lines out of packages, but
# in case there is a package, handle it as a package
@@ -445,13 +477,11 @@ def _change_line_qty(
if not picking.exists():
return self._response_stock_picking_does_not_exist()
- message_directory = self.actions_for("message")
-
move_lines = self.env["stock.move.line"].browse(move_line_ids).exists()
message = None
if not move_lines:
- message = message_directory.record_not_found()
+ message = self.msg_store.record_not_found()
for move_line in move_lines:
qty_done = quantity_func(move_line)
if qty_done > move_line.product_uom_qty:
@@ -641,7 +671,6 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
if not picking.exists():
return self._response_stock_picking_does_not_exist()
search = self.actions_for("search")
- message = self.actions_for("message")
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
@@ -649,7 +678,8 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
if product:
if product.tracking in ("lot", "serial"):
return self._response_for_select_package(
- selected_lines, message=message.scan_lot_on_product_tracked_by_lot()
+ selected_lines,
+ message=self.msg_store.scan_lot_on_product_tracked_by_lot(),
)
product_lines = selected_lines.filtered(lambda l: l.product_id == product)
return self._switch_line_qty_done(picking, selected_lines, product_lines)
@@ -670,7 +700,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
)
return self._response_for_select_package(
- selected_lines, message=message.barcode_not_found()
+ selected_lines, message=self.msg_store.barcode_not_found()
)
def new_package(self, picking_id, selected_line_ids):
@@ -734,34 +764,6 @@ def list_dest_package(self, picking_id, selected_line_ids):
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
return self._response_for_select_dest_package(picking, lines)
- def _response_for_select_dest_package(self, picking, move_lines, message=None):
- packages = picking.mapped("move_line_ids.package_id") | picking.mapped(
- "move_line_ids.result_package_id"
- )
- if not packages:
- return self._response_for_select_package(
- move_lines,
- message={
- "message_type": "warning",
- "body": _("No valid package to select."),
- },
- )
- data_struct = self.actions_for("data")
- picking_data = data_struct.picking(picking)
- packages_data = data_struct.packages(
- packages.sorted(), picking=picking, with_packaging=True
- )
- data_struct = self.actions_for("data")
- return self._response(
- next_state="select_dest_package",
- data={
- "picking": picking_data,
- "packages": packages_data,
- "selected_move_lines": self._data_for_move_lines(move_lines.sorted()),
- },
- message=message,
- )
-
def _set_dest_package_from_selection(self, picking, selected_lines, package):
if not package:
return self._response_for_select_dest_package(picking, selected_lines)
@@ -852,25 +854,6 @@ def list_packaging(self, picking_id, package_id):
packaging_list = self._get_allowed_packaging()
return self._response_for_change_packaging(picking, package, packaging_list)
- def _response_for_change_packaging(self, picking, package, packaging_list):
- message = self.actions_for("message")
- data_struct = self.actions_for("data")
- if not package:
- return self._response_for_summary(
- picking, message=message.record_not_found()
- )
-
- return self._response(
- next_state="change_packaging",
- data={
- "picking": data_struct.picking(picking),
- "package": data_struct.package(
- package, picking=picking, with_packaging=True
- ),
- "packagings": data_struct.packaging_list(packaging_list.sorted()),
- },
- )
-
def set_packaging(self, picking_id, package_id, packaging_id):
"""Set a package type on a package
@@ -878,8 +861,6 @@ def set_packaging(self, picking_id, package_id, packaging_id):
* change_packaging: in case of error
* summary
"""
- message = self.actions_for("message")
-
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
return self._response_stock_picking_does_not_exist()
@@ -888,7 +869,7 @@ def set_packaging(self, picking_id, package_id, packaging_id):
packaging = self.env["product.packaging"].browse(packaging_id).exists()
if not (package and packaging):
return self._response_for_summary(
- picking, message=message.record_not_found()
+ picking, message=self.msg_store.record_not_found()
)
package.product_packaging_id = packaging
return self._response_for_summary(
@@ -915,7 +896,6 @@ def cancel_line(self, picking_id, package_id=None, line_id=None):
Transitions:
* summary
"""
- message = self.actions_for("message")
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
return self._response_stock_picking_does_not_exist()
@@ -924,7 +904,7 @@ def cancel_line(self, picking_id, package_id=None, line_id=None):
line = self.env["stock.move.line"].browse(line_id).exists()
if not package and not line:
return self._response_for_summary(
- picking, message=message.record_not_found()
+ picking, message=self.msg_store.record_not_found()
)
if package:
@@ -1241,7 +1221,7 @@ def _schema_select_packaging(self):
"type": "dict",
"schema": self.schemas.package(with_packaging=True),
},
- "packagings": {
+ "packaging": {
"type": "list",
"schema": {"type": "dict", "schema": self.schemas.packaging()},
},
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index e40d981491..bda81fc7f1 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -71,26 +71,18 @@ class ClusterPicking(Component):
_usage = "cluster_picking"
_description = __doc__
- @property
- def data_struct(self):
- return self.actions_for("data")
-
- @property
- def msg_store(self):
- return self.actions_for("message")
-
def _response_for_start(self, message=None, popup=None):
return self._response(next_state="start", message=message, popup=popup)
def _response_for_confirm_start(self, batch):
return self._response(
next_state="confirm_start",
- data=self.data_struct.picking_batch(batch, with_pickings=True),
+ data=self.data.picking_batch(batch, with_pickings=True),
)
def _response_for_manual_selection(self, batches, message=None):
data = {
- "records": self.data_struct.picking_batches(batches),
+ "records": self.data.picking_batches(batches),
"size": len(batches),
}
return self._response(next_state="manual_selection", data=data, message=message)
@@ -108,9 +100,7 @@ def _response_for_scan_destination(self, move_line, message=None):
last_picked_line = self._last_picked_line(move_line.picking_id)
if last_picked_line:
# suggest pack to be used for the next line
- data["package_dest"] = self.data_struct.package(
- last_picked_line.result_package_id
- )
+ data["package_dest"] = self.data.package(last_picked_line.result_package_id)
return self._response(next_state="scan_destination", data=data, message=message)
def _response_for_change_pack_lot(self, move_line, message=None):
@@ -123,9 +113,9 @@ def _response_for_change_pack_lot(self, move_line, message=None):
def _response_for_zero_check(self, batch, move_line):
data = {
"id": move_line.id,
- "location_src": self.data_struct.location(move_line.location_id),
+ "location_src": self.data.location(move_line.location_id),
}
- data["batch"] = self.data_struct.picking_batch(batch)
+ data["batch"] = self.data.picking_batch(batch)
return self._response(next_state="zero_check", data=data)
def _response_for_unload_all(self, batch, message=None):
@@ -379,14 +369,14 @@ def _data_move_line(self, line, **kw):
picking = line.picking_id
batch = picking.batch_id
product = line.product_id
- data = self.data_struct.move_line(line)
+ data = self.data.move_line(line)
# additional values
# Ensure destination pack is never proposed on the frontend.
# This should happen only as proposal on `scan_destination`
# where we set the last used package.
data["package_dest"] = None
- data["batch"] = self.data_struct.picking_batch(batch)
- data["picking"] = self.data_struct.picking(picking)
+ data["batch"] = self.data.picking_batch(batch)
+ data["picking"] = self.data.picking(picking)
data["postponed"] = line.shopfloor_postponed
data["product"]["qty_available"] = product.with_context(
location=line.location_id.id
@@ -696,21 +686,19 @@ def _data_for_unload_all(self, batch):
# all the lines destinations are the same here, it looks
# only for the first one
first_line = fields.first(lines)
- data = self.data_struct.picking_batch(batch)
- data.update(
- {"location_dest": self.data_struct.location(first_line.location_dest_id)}
- )
+ data = self.data.picking_batch(batch)
+ data.update({"location_dest": self.data.location(first_line.location_dest_id)})
return data
def _data_for_unload_single(self, batch, package):
line = fields.first(
package.planned_move_line_ids.filtered(self._filter_for_unload)
)
- data = self.data_struct.picking_batch(batch)
+ data = self.data.picking_batch(batch)
data.update(
{
- "package": self.data_struct.package(package),
- "location_dest": self.data_struct.location(line.location_dest_id),
+ "package": self.data.package(package),
+ "location_dest": self.data.location(line.location_dest_id),
}
)
return data
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 420a811bf2..18e9681be0 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -34,14 +34,6 @@ class Delivery(Component):
_usage = "delivery"
_description = __doc__
- @property
- def data_struct(self):
- return self.actions_for("data_detail")
-
- @property
- def msg_store(self):
- return self.actions_for("message")
-
def _response_for_deliver(self, picking=None, message=None):
"""Transition to the 'deliver' state
@@ -50,7 +42,7 @@ def _response_for_deliver(self, picking=None, message=None):
return self._response(
next_state="deliver",
data={
- "picking": self.data_struct.picking_detail(picking) if picking else None
+ "picking": self.data_detail.picking_detail(picking) if picking else None
},
message=message,
)
@@ -64,7 +56,7 @@ def _response_for_manual_selection(self, pickings, message=None):
next_state="manual_selection",
data={
"pickings": [
- self.data_struct.picking_detail(picking) for picking in pickings
+ self.data_detail.picking_detail(picking) for picking in pickings
],
},
message=message,
@@ -75,7 +67,7 @@ def _response_for_confirm_done(self, picking, message=None):
return self._response(
next_state="confirm_done",
data={
- "picking": self.data_struct.picking_detail(picking) if picking else None
+ "picking": self.data_detail.picking_detail(picking) if picking else None
},
message=message,
)
diff --git a/shopfloor/services/scan_anything.py b/shopfloor/services/scan_anything.py
index 38371070fd..de8b24dc1f 100644
--- a/shopfloor/services/scan_anything.py
+++ b/shopfloor/services/scan_anything.py
@@ -71,32 +71,36 @@ def _scan_handlers(self):
"""
search = self.actions_for("search")
- data = self.actions_for("data_detail")
schema = self.component(usage="schema_detail")
return (
(
"location",
search.location_from_scan,
- data.location_detail,
+ self.data_detail.location_detail,
schema.location_detail,
),
(
"package",
search.package_from_scan,
- data.package_detail,
+ self.data_detail.package_detail,
schema.package_detail,
),
(
"product",
search.product_from_scan,
- data.product_detail,
+ self.data_detail.product_detail,
schema.product_detail,
),
- ("lot", search.lot_from_scan, data.lot_detail, schema.lot_detail),
+ (
+ "lot",
+ search.lot_from_scan,
+ self.data_detail.lot_detail,
+ schema.lot_detail,
+ ),
(
"transfer",
search.picking_from_scan,
- data.picking_detail,
+ self.data_detail.picking_detail,
schema.picking_detail,
),
)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 51465f1f16..10f9f088d9 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -253,6 +253,18 @@ def _is_public_api_method(self, method_name):
return False
return super()._is_public_api_method(method_name)
+ @property
+ def data(self):
+ return self.actions_for("data")
+
+ @property
+ def data_detail(self):
+ return self.actions_for("data_detail")
+
+ @property
+ def msg_store(self):
+ return self.actions_for("message")
+
class BaseShopfloorProcess(AbstractComponent):
"""Base class for process rest service"""
diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py
index 3345a2f48e..d50be0ba81 100644
--- a/shopfloor/services/single_pack_putaway.py
+++ b/shopfloor/services/single_pack_putaway.py
@@ -18,26 +18,27 @@ class SinglePackPutaway(Component):
# TODO think about not sending back the state when we already
# come from the same state
def _response_for_no_picking_type(self):
- message = self.actions_for("message")
- return self._response(next_state="start", message=message.no_picking_type())
+ return self._response(
+ next_state="start", message=self.msg_store.no_picking_type()
+ )
def _response_for_several_picking_types(self):
- message = self.actions_for("message")
return self._response(
- next_state="start", message=message.several_picking_types()
+ next_state="start", message=self.msg_store.several_picking_types()
)
def _response_for_package_not_found(self, barcode):
- message = self.actions_for("message")
return self._response(
- next_state="start", message=message.package_not_found_for_barcode(barcode)
+ next_state="start",
+ message=self.msg_store.package_not_found_for_barcode(barcode),
)
def _response_for_forbidden_package(self, barcode, picking_type):
- message = self.actions_for("message")
return self._response(
next_state="start",
- message=message.package_not_allowed_in_src_location(barcode, picking_type),
+ message=self.msg_store.package_not_allowed_in_src_location(
+ barcode, picking_type
+ ),
)
def _response_for_forbidden_start(self, existing_operations):
@@ -71,18 +72,16 @@ def _data_after_package_scanned(self, move_line, pack):
}
def _response_for_start_to_confirm(self, move_line, pack):
- message = self.actions_for("message")
return self._response(
data=self._data_after_package_scanned(move_line, pack),
next_state="confirm_start",
- message=message.already_running_ask_confirmation(),
+ message=self.msg_store.already_running_ask_confirmation(),
)
def _response_for_start_success(self, move_line, pack):
- message = self.actions_for("message")
return self._response(
next_state="scan_location",
- message=message.scan_destination(),
+ message=self.msg_store.scan_destination(),
data=self._data_after_package_scanned(move_line, pack),
)
@@ -173,44 +172,43 @@ def _prepare_package_level(self, pack, move):
)
def _response_for_package_level_not_found(self):
- message = self.actions_for("message")
- return self._response(next_state="start", message=message.operation_not_found())
+ return self._response(
+ next_state="start", message=self.msg_store.operation_not_found()
+ )
def _response_for_move_canceled_elsewhere(self):
- message = self.actions_for("message")
return self._response(
- next_state="start", message=message.operation_has_been_canceled_elsewhere()
+ next_state="start",
+ message=self.msg_store.operation_has_been_canceled_elsewhere(),
)
def _response_for_location_not_found(self, move_line, pack):
- message = self.actions_for("message")
return self._response(
next_state="scan_location",
- message=message.no_location_found(),
+ message=self.msg_store.no_location_found(),
data=self._data_after_package_scanned(move_line, pack),
)
def _response_for_forbidden_location(self, move_line, pack):
- message = self.actions_for("message")
return self._response(
next_state="scan_location",
- message=message.dest_location_not_allowed(),
+ message=self.msg_store.dest_location_not_allowed(),
data=self._data_after_package_scanned(move_line, pack),
)
def _response_for_location_need_confirm(self, move_line, pack, to_location):
- message = self.actions_for("message")
return self._response(
next_state="confirm_location",
- message=message.confirm_location_changed(
+ message=self.msg_store.confirm_location_changed(
move_line.location_dest_id, to_location
),
data=self._data_after_package_scanned(move_line, pack),
)
def _response_for_validate_success(self):
- message = self.actions_for("message")
- return self._response(next_state="start", message=message.confirm_pack_moved())
+ return self._response(
+ next_state="start", message=self.msg_store.confirm_pack_moved()
+ )
def validate(self, package_level_id, location_barcode, confirmation=False):
"""Validate the transfer"""
@@ -247,13 +245,11 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
return self._response_for_validate_success()
def _response_for_move_already_processed(self):
- message = self.actions_for("message")
- return self._response(next_state="start", message=message.already_done())
+ return self._response(next_state="start", message=self.msg_store.already_done())
def _response_for_confirm_move_cancellation(self):
- message = self.actions_for("message")
return self._response(
- next_state="start", message=message.confirm_canceled_scan_next_pack()
+ next_state="start", message=self.msg_store.confirm_canceled_scan_next_pack()
)
def cancel(self, package_level_id):
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index adecaf1aed..76b84a3820 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -10,24 +10,16 @@ class SinglePackTransfer(Component):
_usage = "single_pack_transfer"
_description = __doc__
- @property
- def msg_store(self):
- return self.actions_for("message")
-
- @property
- def data_struct(self):
- return self.actions_for("data")
-
def _data_after_package_scanned(self, package_level):
move_line = package_level.move_line_ids[0]
package = package_level.package_id
return {
"id": package_level.id,
"name": package.name,
- "location_src": self.data_struct.location(move_line.location_id),
- "location_dest": self.data_struct.location(package_level.location_dest_id),
- "product": self.data_struct.product(move_line.product_id),
- "picking": self.data_struct.picking(move_line.picking_id),
+ "location_src": self.data.location(move_line.location_id),
+ "location_dest": self.data.location(package_level.location_dest_id),
+ "product": self.data.product(move_line.product_id),
+ "picking": self.data.picking(move_line.picking_id),
}
def _response_for_start(self, message=None, popup=None):
@@ -193,10 +185,11 @@ def _set_destination_and_done(self, move, scanned_location):
move._action_done()
def cancel(self, package_level_id):
- message = self.actions_for("message")
package_level = self.env["stock.package_level"].browse(package_level_id)
if not package_level.exists():
- return self._response_for_start(message=message.operation_not_found())
+ return self._response_for_start(
+ message=self.msg_store.operation_not_found()
+ )
# package.move_ids may be empty, it seems
move = package_level.move_line_ids.move_id
if move.state == "done":
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
index 1ab4a35fb7..4fd5097dc9 100644
--- a/shopfloor/tests/test_delivery_base.py
+++ b/shopfloor/tests/test_delivery_base.py
@@ -69,7 +69,7 @@ def setUp(self):
self.service = work.component(usage="delivery")
def _stock_picking_data(self, picking):
- return self.service.data_struct.picking_detail(picking)
+ return self.service.data_detail.picking_detail(picking)
def assert_response_deliver(self, response, picking=None, message=None):
self.assert_response(
From 0ec163bc3d55364fc7da2a109f10fe5a96678cda Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 5 Jun 2020 11:16:14 +0200
Subject: [PATCH 256/940] backend: unify 'move_lines' key on picking data
---
shopfloor/actions/data_detail.py | 2 +-
shopfloor/services/schema_detail.py | 2 +-
shopfloor/tests/test_actions_data_detail.py | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py
index b118e86e93..7e577ba9cc 100644
--- a/shopfloor/actions/data_detail.py
+++ b/shopfloor/actions/data_detail.py
@@ -46,7 +46,7 @@ def _picking_detail_parser(self):
("picking_type_id:operation_type", ["id", "name"]),
("carrier_id:carrier", ["id", "name"]),
(
- "move_line_ids:lines",
+ "move_line_ids:move_lines",
lambda record, fname: self.move_lines(record[fname]),
),
]
diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py
index 7736db7a8d..03fea1c23a 100644
--- a/shopfloor/services/schema_detail.py
+++ b/shopfloor/services/schema_detail.py
@@ -38,7 +38,7 @@ def picking_detail(self):
},
"operation_type": self._schema_dict_of(self._simple_record()),
"carrier": self._schema_dict_of(self._simple_record()),
- "lines": self._schema_list_of(self.move_line()),
+ "move_lines": self._schema_list_of(self.move_line()),
}
)
return schema
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index d715d4aefa..0fef6ab21f 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -178,7 +178,7 @@ def test_data_picking(self):
"name": picking.picking_type_id.name,
},
"carrier": {"id": carrier.id, "name": carrier.name},
- "lines": self.data_detail.move_lines(picking.move_line_ids),
+ "move_lines": self.data_detail.move_lines(picking.move_line_ids),
}
self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13")
self.maxDiff = None
From 9ff87327d48d686eef75ad6add0a8fbb09e9be80 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 10 Jun 2020 10:20:56 +0200
Subject: [PATCH 257/940] backend: skip packaging w/out qty
---
shopfloor/actions/data.py | 9 +++++-
shopfloor/tests/test_actions_data.py | 41 +++++++++++++++++++++++++---
2 files changed, 45 insertions(+), 5 deletions(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index c884cfa71c..326e19ff91 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -162,10 +162,17 @@ def _product_parser(self):
"display_name",
"default_code",
"barcode",
- ("packaging_ids:packaging", self._packaging_parser),
+ ("packaging_ids:packaging", self._product_packaging),
("uom_id:uom", self._simple_record_parser() + ["factor", "rounding"]),
]
+ def _product_packaging(self, rec, field):
+ return self._jsonify(
+ rec.packaging_ids.filtered(lambda x: x.qty),
+ self._packaging_parser,
+ multi=True,
+ )
+
def picking_batch(self, record, with_pickings=False, **kw):
parser = self._picking_batch_parser
if with_pickings:
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 53deab34d3..e266525036 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -51,20 +51,24 @@ def assert_schema(self, schema, data):
self.assertTrue(validator.validate(data), validator.errors)
def _expected_location(self, record, **kw):
- return {
+ data = {
"id": record.id,
"name": record.name,
"barcode": record.barcode,
}
+ data.update(kw)
+ return data
def _expected_product(self, record, **kw):
- return {
+ data = {
"id": record.id,
"name": record.name,
"display_name": record.display_name,
"default_code": record.default_code,
"barcode": record.barcode,
- "packaging": [self._expected_packaging(x) for x in record.packaging_ids],
+ "packaging": [
+ self._expected_packaging(x) for x in record.packaging_ids if x.qty
+ ],
"uom": {
"factor": record.uom_id.factor,
"id": record.uom_id.id,
@@ -72,13 +76,17 @@ def _expected_product(self, record, **kw):
"rounding": record.uom_id.rounding,
},
}
+ data.update(kw)
+ return data
def _expected_packaging(self, record, **kw):
- return {
+ data = {
"id": record.id,
"name": record.name,
"qty": record.qty,
}
+ data.update(kw)
+ return data
class ActionsDataCase(ActionsDataCaseBase):
@@ -140,6 +148,31 @@ def test_data_picking(self):
}
self.assertDictEqual(data, expected)
+ def test_data_product(self):
+ (
+ self.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "name": "Box 2",
+ "product_id": self.product_a.id,
+ "barcode": "ProductABox2",
+ }
+ )
+ )
+ self.product_a.packaging_ids.write({"qty": 0})
+ data = self.data.product(self.product_a)
+ self.assert_schema(self.schema.product(), data)
+ # No packaging expected as all qties are zero
+ expected = self._expected_product(self.product_a)
+ self.assertDictEqual(data, expected)
+ # packaging w/ no zero qty are included
+ self.product_a.packaging_ids[0].write({"qty": 100})
+ self.product_a.packaging_ids[1].write({"qty": 20})
+ data = self.data.product(self.product_a)
+ expected = self._expected_product(self.product_a)
+ self.assertDictEqual(data, expected)
+
def test_data_move_line_package(self):
move_line = self.move_a.move_line_ids
result_package = self.env["stock.quant.package"].create(
From d685130e62384b581a96b30fa7e8fd32e1f49bef Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 23 Jun 2020 15:46:31 +0200
Subject: [PATCH 258/940] backend: add option on menu to allow move(s) creation
---
shopfloor/models/shopfloor_menu.py | 38 +++++++++++++++++++++++++++---
shopfloor/views/shopfloor_menu.xml | 5 ++++
2 files changed, 40 insertions(+), 3 deletions(-)
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index 0074c72999..b7adbc2d14 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -1,4 +1,4 @@
-from odoo import fields, models
+from odoo import _, api, exceptions, fields, models
class ShopfloorMenu(models.Model):
@@ -6,18 +6,30 @@ class ShopfloorMenu(models.Model):
_description = "Menu displayed in the scanner application"
_order = "sequence"
+ _scenario_allowing_create_moves = ("single_pack_transfer",)
+
name = fields.Char(translate=True)
sequence = fields.Integer()
profile_ids = fields.Many2many(
"shopfloor.profile", string="Profiles", help="Visible for these profiles"
)
picking_type_ids = fields.Many2many(
- comodel_name="stock.picking.type", string="Operation Types", required=True,
+ comodel_name="stock.picking.type", string="Operation Types", required=True
)
- # TODO allow only one picking type when 'move creation' is allowed
scenario = fields.Selection(selection="_selection_scenario", required=True)
+ move_create_is_possible = fields.Boolean(compute="_compute_move_create_is_possible")
+ # only available for some scenarios, move_create_is_possible defines if the option
+ # can be used or not
+ allow_move_create = fields.Boolean(
+ string="Allow Move Creation",
+ default=False,
+ help="Some scenario may create move(s) when a product or package is"
+ " scanned and no move already exists. Any new move is created in the"
+ " selected operation type, so it can be active only when one type is selected.",
+ )
+
def _selection_scenario(self):
return [
# these must match a REST service's '_usage'
@@ -27,3 +39,23 @@ def _selection_scenario(self):
("checkout", "Checkout/Packing"),
("delivery", "Delivery"),
]
+
+ @api.depends("scenario", "picking_type_ids")
+ def _compute_move_create_is_possible(self):
+ for menu in self:
+ menu.move_create_is_possible = bool(
+ menu.scenario in self._scenario_allowing_create_moves
+ and len(menu.picking_type_ids) == 1
+ )
+
+ @api.onchange("move_create_is_possible")
+ def onchange_move_create_is_possible(self):
+ self.allow_move_create = self.move_create_is_possible
+
+ @api.constrains("scenario", "picking_type_ids", "allow_move_create")
+ def _check_allow_move_create(self):
+ for menu in self:
+ if menu.allow_move_create and not menu.move_create_is_possible:
+ raise exceptions.ValidationError(
+ _("Creation of moves is not allowed for menu {}.").format(menu.name)
+ )
diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml
index 8b611b17d7..68759df75b 100644
--- a/shopfloor/views/shopfloor_menu.xml
+++ b/shopfloor/views/shopfloor_menu.xml
@@ -18,6 +18,11 @@
widget="many2many_tags"
options="{'no_create': 1}"
/>
+
+
From 6b96df1a7f48f89daa80224a60a9cfb92905a47b Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 24 Jun 2020 07:46:08 +0200
Subject: [PATCH 259/940] pack transfer: add option that creates the move
before moving
It replaces the single pack putaway as pack put-away and transfer were
doing the same thing, except put-away was creating the move and
transfer was working only on existing moves.
---
shopfloor/services/single_pack_transfer.py | 47 +++++++++++++++----
shopfloor/tests/test_single_pack_transfer.py | 48 ++++++++++++++++++++
2 files changed, 87 insertions(+), 8 deletions(-)
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 76b84a3820..085f851a56 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -85,25 +85,56 @@ def start(self, barcode):
)
)
- move_lines = self.env["stock.move.line"].search(
+ package_level = self.env["stock.package_level"].search(
[
("package_id", "=", package.id),
- ("state", "not in", ("cancel", "done")),
("picking_id.picking_type_id", "in", picking_types.ids),
]
)
- if not move_lines:
+ # State is computed, can't use it in the domain. And it's probably faster
+ # to filter here rather than using a domain on "picking_id.state" that would
+ # use a sub-search on stock.picking: we shouldn't have dozens of package levels
+ # for a package.
+ package_level = package_level.filtered(
+ lambda pl: pl.state not in ("cancel", "done")
+ )
+ if not package_level:
+ if self.work.menu.allow_move_create:
+ package_level = self._create_package_level(package)
+
+ if not package_level:
return self._response_for_start(
message=self.msg_store.no_pending_operation_for_pack(package)
)
- if move_lines[0].package_level_id.is_done:
+
+ if package_level.is_done:
return self._response_for_confirm_start(
- move_lines[0].package_level_id,
- message=self.msg_store.already_running_ask_confirmation(),
+ package_level, message=self.msg_store.already_running_ask_confirmation()
)
- move_lines[0].package_level_id.is_done = True
- return self._response_for_scan_location(move_lines[0].package_level_id)
+ package_level.is_done = True
+ return self._response_for_scan_location(package_level)
+
+ def _create_package_level(self, package):
+ # this method can be called only if we have one picking type
+ # (allow_move_create==True on menu)
+ assert self.picking_types.ensure_one()
+ StockPicking = self.env["stock.picking"].with_context(
+ default_picking_type_id=self.picking_types.id
+ )
+ picking = StockPicking.create({})
+ package_level = self.env["stock.package_level"].create(
+ {
+ "picking_id": picking.id,
+ "package_id": package.id,
+ "location_dest_id": picking.location_dest_id.id,
+ "company_id": self.env.company.id,
+ }
+ )
+ package_level._generate_moves()
+ picking.action_confirm()
+ picking.action_assign()
+ return package_level
def _is_move_state_valid(self, move):
return move.state != "cancel"
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index dd4dca4695..aec7b9ca9a 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -15,6 +15,8 @@ def setUpClassVars(cls, *args, **kwargs):
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
+ # we activate the move creation in tests when needed
+ cls.menu.sudo().allow_move_create = False
cls.pack_a = cls.env["stock.quant.package"].create(
{"location_id": cls.stock_location.id}
)
@@ -140,6 +142,52 @@ def test_start_no_operation(self):
},
)
+ def test_start_no_operation_create(self):
+ """Test /start when there is no operation to move the pack, it is created
+
+ The pre-conditions:
+
+ * The option "Allow Move Creation" is turned on on the menu
+ * A Pack exists in Stock/Shelf1.
+ * No stock picking exists to move the Pack from Stock/Shelf1 to
+ Stock/Shelf2, or the state is not assigned.
+
+ Expected result:
+
+ * Create a stock.picking, move, package level and continue with the
+ workflow
+ """
+ self.menu.sudo().allow_move_create = True
+ barcode = self.pack_a.name
+ params = {"barcode": barcode}
+ self.picking.do_unreserve()
+
+ # Simulate the client scanning a package's barcode, which
+ # in turns should start the operation in odoo
+ response = self.service.dispatch("start", params=params)
+
+ move_line = self.env["stock.move.line"].search(
+ [("package_id", "=", self.pack_a.id)]
+ )
+ package_level = move_line.package_level_id
+
+ self.assertTrue(package_level.is_done)
+
+ expected_data = {
+ "id": package_level.id,
+ "name": package_level.package_id.name,
+ "location_src": self.data.location(self.shelf1),
+ "location_dest": self.data.location(
+ self.picking_type.default_location_dest_id
+ ),
+ "picking": self.data.picking(package_level.picking_id),
+ "product": self.data.product(self.product_a),
+ }
+
+ self.assert_response(
+ response, next_state="scan_location", data=expected_data,
+ )
+
def test_start_barcode_not_known(self):
"""Test /start when the barcode is unknown
From e48cf8059ac3fef5fc1669fbf8e71d5d9bae56e8 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 24 Jun 2020 07:55:09 +0200
Subject: [PATCH 260/940] pack putaway: remove
It has been replaced by a new option "create move" in the single pack
transfer scenario.
---
shopfloor/actions/__init__.py | 1 -
shopfloor/actions/pack_transfer_validate.py | 35 --
shopfloor/demo/shopfloor_menu_demo.xml | 10 +-
shopfloor/demo/stock_picking_type_demo.xml | 15 -
shopfloor/models/shopfloor_menu.py | 1 -
shopfloor/services/__init__.py | 1 -
shopfloor/services/single_pack_putaway.py | 355 -----------
shopfloor/tests/__init__.py | 1 -
shopfloor/tests/test_single_pack_putaway.py | 624 --------------------
9 files changed, 1 insertion(+), 1042 deletions(-)
delete mode 100644 shopfloor/actions/pack_transfer_validate.py
delete mode 100644 shopfloor/services/single_pack_putaway.py
delete mode 100644 shopfloor/tests/test_single_pack_putaway.py
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index 58c70c29f3..ac8c5da74d 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -21,6 +21,5 @@
from . import data_detail
from . import completion_info
from . import message
-from . import pack_transfer_validate
from . import search
from . import inventory
diff --git a/shopfloor/actions/pack_transfer_validate.py b/shopfloor/actions/pack_transfer_validate.py
deleted file mode 100644
index 7b80f0aae9..0000000000
--- a/shopfloor/actions/pack_transfer_validate.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from odoo.addons.component.core import Component
-
-
-# TODO remove
-# TODO think BETTER about how we want to share the common methods / workflows
-class PackTransferValidateAction(Component):
- """Pack Transfer shared business logic
-
- This component is shared by the "validate" action of the processes:
-
- * single_pack_putaway
- * single_pack_transfer
- """
-
- _name = "shopfloor.pack.transfer.validate.action"
- _inherit = "shopfloor.process.action"
- _usage = "pack.transfer.validate"
-
- def is_move_state_valid(self, move):
- return move.state != "cancel"
-
- def is_dest_location_valid(self, move, scanned_location):
- """Forbid a dest location to be used"""
- return scanned_location.is_sublocation_of(
- move.picking_id.picking_type_id.default_location_dest_id
- )
-
- def is_dest_location_to_confirm(self, move, scanned_location):
- """Destination that could be used but need confirmation"""
- move_dest_location = move.move_line_ids[0].location_dest_id
- return not scanned_location.is_sublocation_of(move_dest_location)
-
- def set_destination_and_done(self, move, scanned_location):
- move.move_line_ids[0].location_dest_id = scanned_location.id
- move._action_done()
diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml
index 1276f19a98..0c2f671777 100644
--- a/shopfloor/demo/shopfloor_menu_demo.xml
+++ b/shopfloor/demo/shopfloor_menu_demo.xml
@@ -1,16 +1,8 @@
-
From c4f48400af3e25fb9ecde104be03bc7a4c760fea Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 7 Jul 2020 09:26:45 +0200
Subject: [PATCH 262/940] Add option to disable zero check in scenario
Currently, it applies only for Cluster Picking, it will apply
on Zone Picking and Discrete Order Picking too.
When a location becomes empty and the option is active, the user has
to validate that the location is really empty. It doesn't really make
sense for picking types that move pallets, so they would disable it
there.
There is an ongoing discussion whether this option should be on the
picking type or on the shopfloor menu, it is possible that this option
will be moved.
---
shopfloor/demo/stock_picking_type_demo.xml | 1 +
shopfloor/models/stock_picking_type.py | 6 +++
shopfloor/services/cluster_picking.py | 3 +-
shopfloor/tests/test_cluster_picking_scan.py | 39 +++++++++++++++++++-
shopfloor/views/stock_picking_type.xml | 12 ++++--
5 files changed, 56 insertions(+), 5 deletions(-)
diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml
index 09c8042865..c64cf5060d 100644
--- a/shopfloor/demo/stock_picking_type_demo.xml
+++ b/shopfloor/demo/stock_picking_type_demo.xml
@@ -28,6 +28,7 @@
internal
+
Checkout
diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py
index f974b0257a..cbe326fc8d 100644
--- a/shopfloor/models/stock_picking_type.py
+++ b/shopfloor/models/stock_picking_type.py
@@ -7,3 +7,9 @@ class StockPickingType(models.Model):
shopfloor_menu_ids = fields.Many2many(
comodel_name="shopfloor.menu", string="Shopfloor Menus", readonly=True,
)
+ shopfloor_zero_check = fields.Boolean(
+ string="Activate Zero Check",
+ help="For Shopfloor scenarios using it (Cluster Picking, Zone Picking,"
+ " Discrete order Picking), the zero check step will be activated when"
+ " a location becomes empty after a move.",
+ )
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 75c849ebc7..88fe45175d 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -622,7 +622,8 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit
)
move_line.write({"qty_done": quantity, "result_package_id": bin_package.id})
- if self._planned_qty_in_location_is_empty(
+ zero_check = move_line.picking_id.picking_type_id.shopfloor_zero_check
+ if zero_check and self._planned_qty_in_location_is_empty(
move_line.product_id, move_line.location_id
):
return self._response_for_zero_check(batch, move_line)
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 11909cccf0..3c097a4ee8 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -513,9 +513,11 @@ def test_scan_destination_pack_quantity_less(self):
# the reserved quantity on the quant must stay the same
self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}])
- def test_scan_destination_pack_zero_check(self):
+ def test_scan_destination_pack_zero_check_activated(self):
"""Location will be emptied, have to go to zero check"""
line = self.one_line_picking.move_line_ids
+ # ensure we have activated the zero check
+ self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = True
# Update the quantity in the location to be equal to the line's
# so when scan_destination_pack sets the qty_done, the planned
# qty should be zero and trigger a zero check
@@ -542,6 +544,41 @@ def test_scan_destination_pack_zero_check(self):
},
)
+ def test_scan_destination_pack_zero_check_disabled(self):
+ """Location will be emptied, no zero check, continue"""
+ line = self.one_line_picking.move_line_ids
+ # ensure we have deactivated the zero check
+ self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = False
+ # Update the quantity in the location to be equal to the line's
+ # so when scan_destination_pack sets the qty_done, the planned
+ # qty should be zero and trigger a zero check
+ self._update_qty_in_location(
+ line.location_id, line.product_id, line.product_uom_qty
+ )
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": line.id,
+ "barcode": self.bin1.name,
+ "quantity": line.product_uom_qty,
+ },
+ )
+
+ next_line = self.batch.picking_ids.move_line_ids[1]
+ # continue to the next one, no zero check
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(next_line),
+ message={
+ "message_type": "success",
+ "body": "{} {} put in {}".format(
+ line.qty_done, line.product_id.display_name, self.bin1.name
+ ),
+ },
+ )
+
class ClusterPickingIsZeroCase(ClusterPickingCommonCase):
"""Tests covering the /is_zero endpoint
diff --git a/shopfloor/views/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml
index 25313e1586..ad90a59d5b 100644
--- a/shopfloor/views/stock_picking_type.xml
+++ b/shopfloor/views/stock_picking_type.xml
@@ -5,9 +5,15 @@
stock.picking.type
-
-
-
+
+
+
+
+
+
From aa54cfc83419878e3f3755f8b56c34e5b4af7139 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 10 Jun 2020 15:39:00 +0200
Subject: [PATCH 263/940] location content transfer: add skeleton
---
shopfloor/demo/shopfloor_menu_demo.xml | 9 +
shopfloor/demo/stock_picking_type_demo.xml | 15 +
shopfloor/models/shopfloor_menu.py | 1 +
shopfloor/models/stock_picking.py | 6 +
shopfloor/services/__init__.py | 1 +
.../services/location_content_transfer.py | 439 ++++++++++++++++++
shopfloor/tests/__init__.py | 1 +
.../test_location_content_transfer_base.py | 20 +
8 files changed, 492 insertions(+)
create mode 100644 shopfloor/services/location_content_transfer.py
create mode 100644 shopfloor/tests/test_location_content_transfer_base.py
diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml
index 0c2f671777..2880a74a80 100644
--- a/shopfloor/demo/shopfloor_menu_demo.xml
+++ b/shopfloor/demo/shopfloor_menu_demo.xml
@@ -36,4 +36,13 @@
eval="[(4, ref('shopfloor.picking_type_delivery_demo'))]"
/>
+
diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml
index c64cf5060d..2dbedd83ad 100644
--- a/shopfloor/demo/stock_picking_type_demo.xml
+++ b/shopfloor/demo/stock_picking_type_demo.xml
@@ -60,4 +60,19 @@
+
+ Location Content Transfer
+ LCT
+
+
+
+
+
+
+
+
+ internal
+
+
+
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index 362c96c58a..269d87aa01 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -37,6 +37,7 @@ def _selection_scenario(self):
("cluster_picking", "Cluster Picking"),
("checkout", "Checkout/Packing"),
("delivery", "Delivery"),
+ ("location_content_transfer", "Location Content Transfer"),
]
@api.depends("scenario", "picking_type_ids")
diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py
index d266f38cce..cdcd93cd08 100644
--- a/shopfloor/models/stock_picking.py
+++ b/shopfloor/models/stock_picking.py
@@ -30,3 +30,9 @@ def _calc_weight(self):
for move_line in self.mapped("move_line_ids"):
weight += move_line.product_qty * move_line.product_id.weight
return weight
+
+ def _create_backorder(self):
+ if self.env.context.get("_sf_no_backorder"):
+ return self.browse()
+ else:
+ return super()._create_backorder()
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 85000d1508..09470e83ee 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -14,4 +14,5 @@
from . import checkout
from . import cluster_picking
from . import delivery
+from . import location_content_transfer
from . import single_pack_transfer
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
new file mode 100644
index 0000000000..d8e98a9366
--- /dev/null
+++ b/shopfloor/services/location_content_transfer.py
@@ -0,0 +1,439 @@
+from odoo.addons.base_rest.components.service import to_int
+from odoo.addons.component.core import Component
+
+from .service import to_float
+
+# NOTE for the implementation: share several similarities with the "cluster
+# picking" scenario
+
+
+class LocationContentTransfer(Component):
+ """
+ Methods for the Location Content Transfer Process
+
+ Move the full content of a location to one or another location.
+
+ Generally used to move a pallet with multiple boxes to either:
+
+ * 1 destination location, unloading the full pallet
+ * To multiple destination locations, unloading one product/lot per
+ locations
+ * To multiple destination locations, unloading one product/lot per
+ locations and then unloading all remaining product/lot to a single final
+ destination
+
+ The move lines must exist beforehand, the workflow only moves them.
+
+ Expected:
+
+ * All move lines and package level have a destination set, and are done.
+
+ 2 complementary actions are possible on the screens allowing to move a line:
+
+ * Declare a stock out for a product or package (nothing found in the
+ location)
+ * Skip to the next line (will be asked again at the end)
+
+ Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP
+ """
+
+ _inherit = "base.shopfloor.process"
+ _name = "shopfloor.location.content.transfer"
+ _usage = "location_content_transfer"
+ _description = __doc__
+
+ def _response_for_start(self, message=None):
+ """Transition to the 'start' state"""
+ return self._response(next_state="start", message=message)
+
+ def _response_for_scan_destination_all(self, location, message=None):
+ """Transition to the 'scan_destination_all' state
+
+ The client screen shows a summary of all the lines and packages
+ to move to a single destination.
+ """
+ return self._response(
+ next_state="scan_destination_all",
+ data=self._data_content_all_for_location(location),
+ message=message,
+ )
+
+ def _response_for_start_single(
+ self, location, package_level=None, line=None, message=None
+ ):
+ """Transition to the 'start_single' state
+
+ The client screen shows details of the package level or move line to move.
+ """
+ assert package_level or line
+ return self._response(
+ next_state="start_single",
+ data=self._data_content_line_for_location(
+ location, package_level=package_level, line=line
+ ),
+ message=message,
+ )
+
+ def _response_for_scan_destination(
+ self, location, package_level=None, line=None, message=None
+ ):
+ """Transition to the 'start_single' state
+
+ The client screen shows details of the package level or move line to move.
+ """
+ assert package_level or line
+ return self._response(
+ next_state="scan_destination",
+ data=self._data_content_line_for_location(
+ location, package_level=package_level, line=line
+ ),
+ message=message,
+ )
+
+ def _data_content_all_for_location(self, location):
+ return {}
+
+ def _data_content_line_for_location(self, location, package_level=None, line=None):
+ assert package_level or line
+ return {}
+
+ def start_or_recover(self):
+ """Start a new session or recover an existing one
+
+ If the current user had transfers in progress in this scenario
+ and reopen the menu, we want to directly reopen the screens to choose
+ destinations. Otherwise, we go to the "start" state.
+ """
+ # TODO if we find any stock.picking != done with current user as user id
+ # and with move lines having a qty_done > 0, in the current picking types,
+ # reach start_single or scan_destination_all
+ return self._response_for_start()
+
+ def scan_location(self, barcode):
+ """Scan start location
+
+ Called at the beginning at the workflow to select the location from which
+ we want to move the content.
+
+ All the move lines and package levels must have the same picking type.
+
+ When move lines and package levels have different destinations, the
+ first line without package level or package level is sent to the client.
+
+ Transitions:
+ * start: location not found, ...
+ * scan_destination_all: if the destination of all the lines and package
+ levels have the same destination
+ * start_single: if any line or package level has a different destination
+ """
+ return self._response()
+
+ def set_destination_all(self, location_id, barcode):
+ """Scan destination location for all the moves of the location
+
+ barcode is a stock.location for the destination
+
+ Transitions:
+ * scan_destination_all: invalid destination or could not set moves to done
+ * start: moves are done
+ """
+ return self._response()
+
+ def go_to_single(self, location_id):
+ """Ask the first move line or package level
+
+ If the user was brought to the screen allowing to move everything to
+ the same location, but they want to move them to different locations,
+ this method will return the first move line or package level.
+
+ Transitions:
+ * start: no remaining lines in the location
+ * start_single: if any line or package level has a different destination
+ """
+ return self._response()
+
+ def scan_package(self, location_id, package_level_id, barcode):
+ """Scan a package level to move
+
+ It validates that the user scanned the correct package, lot or product.
+
+ Transitions:
+ * start: no remaining lines in the location
+ * start_single: barcode not found, ...
+ * scan_destination: the barcode matches
+ """
+ return self._response()
+
+ def scan_line(self, location_id, move_line_id, barcode):
+ """Scan a move line to move
+
+ It validates that the user scanned the correct package, lot or product.
+
+ Transitions:
+ * start: no remaining lines in the location
+ * start_single: barcode not found, ...
+ * scan_destination: the barcode matches
+ """
+ return self._response()
+
+ def set_destination_package(self, location_id, package_level_id, barcode):
+ """Scan destination location for package level
+
+ If the move has other move lines / package levels it has to be split
+ so we can post only this part.
+
+ After the destination is set, the move is set to done.
+
+ Beware, when _action_done() is called on the move, the normal behavior
+ of Odoo would be to create a backorder transfer. We don't want this or
+ we would have a backorder per move. The context key
+ ``_sf_no_backorder`` disables the creation of backorders, it must be set
+ on all moves, but the last one of a transfer (so in case something was not
+ available, a backorder is created).
+
+ Transitions:
+ * scan_destination: invalid destination or could not
+ * start_single: continue with the next package level / line
+ """
+ return self._response()
+
+ def set_destination_line(self, location_id, move_line_id, quantity, barcode):
+ """Scan destination location for move line
+
+ If the quantity < qty of the line, split the move and reserve it.
+ If the move has other move lines / package levels it has to be split
+ so we can post only this part.
+
+ After the destination and quantity are set, the move is set to done.
+
+ Beware, when _action_done() is called on the move, the normal behavior
+ of Odoo would be to create a backorder transfer. We don't want this or
+ we would have a backorder per move. The context key
+ ``_sf_no_backorder`` disables the creation of backorders, it must be set
+ on all moves, but the last one of a transfer (so in case something was not
+ available, a backorder is created).
+
+ Transitions:
+ * scan_destination: invalid destination or could not
+ * start_single: continue with the next package level / line
+ """
+ return self._response()
+
+ def postpone_package(self, location_id, package_level_id):
+ """Mark a package level as postponed and return the next level/line
+
+ NOTE for implementation: Use the field "shopfloor_postponed", which has
+ to be included in the sort to get the next lines.
+
+ Transitions:
+ * start_single: continue with the next package level / line
+ """
+ return self._response()
+
+ def postpone_line(self, location_id, move_line_id):
+ """Mark a move line as postponed and return the next level/line
+
+ NOTE for implementation: Use the field "shopfloor_postponed", which has
+ to be included in the sort to get the next lines.
+
+ Transitions:
+ * start_single: continue with the next package level / line
+ """
+ return self._response()
+
+ def stock_out_package(self, location_id, package_level_id):
+ """Declare a stock out on a package level
+
+ It first ensures the stock.move only has this package level. If not, it
+ splits the move to have no side-effect on the other package levels/move
+ lines.
+
+ It unreserves the move, create an inventory at 0 in the move's source
+ location, create a second draft inventory (if none exists) to check later.
+ Finally, it cancels the move.
+
+ Transitions:
+ * start: no more content to move
+ * start_single: continue with the next package level / line
+ """
+ return self._response()
+
+ def stock_out_line(self, location_id, move_line_id):
+ """Declare a stock out on a move line
+
+ It first ensures the stock.move only has this move line. If not, it
+ splits the move to have no side-effect on the other package levels/move
+ lines.
+
+ It unreserves the move, create an inventory at 0 in the move's source
+ location, create a second draft inventory (if none exists) to check later.
+ Finally, it cancels the move.
+
+ Transitions:
+ * start: no more content to move
+ * start_single: continue with the next package level / line
+ """
+ return self._response()
+
+
+class ShopfloorLocationContentTransferValidator(Component):
+ """Validators for the Location Content Transfer endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.location.content.transfer.validator"
+ _usage = "location_content_transfer.validator"
+
+ def start_or_recover(self):
+ return {}
+
+ def scan_location(self):
+ return {"barcode": {"required": True, "type": "string"}}
+
+ def set_destination_all(self):
+ return {
+ "location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def go_to_single(self):
+ return {"location_id": {"coerce": to_int, "required": True, "type": "integer"}}
+
+ def scan_package(self):
+ return {
+ "location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def scan_line(self):
+ return {
+ "location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def set_destination_package(self):
+ return {
+ "location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def set_destination_line(self):
+ return {
+ "location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "quantity": {"coerce": to_float, "required": True, "type": "float"},
+ "barcode": {"required": True, "type": "string"},
+ }
+
+ def postpone_package(self):
+ return {
+ "location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def postpone_line(self):
+ return {
+ "location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def stock_out_package(self):
+ return {
+ "location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def stock_out_line(self):
+ return {
+ "location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+
+class ShopfloorLocationContentTransferValidatorResponse(Component):
+ """Validators for the Location Content Transfer endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.location.content.transfer.validator.response"
+ _usage = "location_content_transfer.validator.response"
+
+ def _states(self):
+ """List of possible next states
+
+ With the schema of the data send to the client to transition
+ to the next state.
+ """
+ return {
+ "start": {},
+ "scan_destination_all": self._schema_all,
+ "start_single": self._schema_single,
+ "scan_destination": self._schema_single,
+ }
+
+ @property
+ def _schema_all(self):
+ package_schema = self.schemas.package()
+ move_line_schema = self.schemas.move_line()
+ return {
+ # we'll display all the packages and move lines *without package
+ # levels*
+ "packages": self.schemas._schema_list_of(package_schema),
+ "move_lines": self.schemas._schema_list_of(move_line_schema),
+ }
+
+ @property
+ def _schema_single(self):
+ schema_package_level = self.schemas.package_level()
+ schema_move_line = self.schemas.move_line()
+ return {
+ # we'll have one or the other...
+ # TODO add the package in the package_level
+ "package_level": self.schemas._schema_dict_of(schema_package_level),
+ "move_line": self.schemas._schema_dict_of(schema_move_line),
+ }
+
+ def start_or_recover(self):
+ return self._response_schema(
+ next_states={"start", "scan_destination_all", "start_single"}
+ )
+
+ def scan_location(self):
+ return self._response_schema(
+ next_states={"start", "scan_destination_all", "start_single"}
+ )
+
+ def set_destination_all(self):
+ return self._response_schema(next_states={"start", "scan_destination_all"})
+
+ def go_to_single(self):
+ return self._response_schema(next_states={"start", "start_single"})
+
+ def scan_package(self):
+ return self._response_schema(
+ next_states={"start", "start_single", "scan_destination"}
+ )
+
+ def scan_line(self):
+ return self._response_schema(
+ next_states={"start", "start_single", "scan_destination"}
+ )
+
+ def set_destination_package(self):
+ return self._response_schema(next_states={"start_single", "scan_destination"})
+
+ def set_destination_line(self):
+ return self._response_schema(next_states={"start_single", "scan_destination"})
+
+ def postpone_package(self):
+ return self._response_schema(next_states={"start_single"})
+
+ def postpone_line(self):
+ return self._response_schema(next_states={"start_single"})
+
+ def stock_out_package(self):
+ return self._response_schema(next_states={"start", "start_single"})
+
+ def stock_out_line(self):
+ return self._response_schema(next_states={"start", "start_single"})
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 5b470c54df..fb9f55a585 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -33,3 +33,4 @@
from . import test_delivery_set_qty_done_line
from . import test_delivery_list_stock_picking
from . import test_delivery_select
+from . import test_location_content_transfer_base
diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py
new file mode 100644
index 0000000000..f66e8be98f
--- /dev/null
+++ b/shopfloor/tests/test_location_content_transfer_base.py
@@ -0,0 +1,20 @@
+from .common import CommonCase
+
+
+class LocationContentTransferCommonCase(CommonCase):
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_location_content_transfer")
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.picking_type = cls.menu.picking_type_ids
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
+ self.service = work.component(usage="location_content_transfer")
From 221a785c06d77e3d07fa7a56371aa4cc82997688 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 17 Jun 2020 14:49:08 +0200
Subject: [PATCH 264/940] location transfer: implement /start_or_recover,
/scan_location
---
shopfloor/actions/__init__.py | 1 +
shopfloor/actions/data.py | 25 +++
.../location_content_transfer_sorter.py | 56 ++++++
shopfloor/actions/message.py | 14 ++
.../services/location_content_transfer.py | 142 +++++++++++---
shopfloor/services/schema.py | 5 +-
shopfloor/services/single_pack_transfer.py | 11 +-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/common.py | 6 +-
.../test_location_content_transfer_base.py | 56 ++++++
.../test_location_content_transfer_start.py | 174 ++++++++++++++++++
11 files changed, 458 insertions(+), 33 deletions(-)
create mode 100644 shopfloor/actions/location_content_transfer_sorter.py
create mode 100644 shopfloor/tests/test_location_content_transfer_start.py
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index ac8c5da74d..42f45d7eee 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -20,6 +20,7 @@
from . import data
from . import data_detail
from . import completion_info
+from . import location_content_transfer_sorter
from . import message
from . import search
from . import inventory
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 326e19ff91..c2efca8ba6 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -148,6 +148,31 @@ def _move_line_parser(self):
("location_dest_id:location_dest", self._location_parser),
]
+ def package_level(self, record, **kw):
+ data = self._jsonify(record, self._package_level_parser)
+ if data:
+ data.update(
+ {
+ # cannot use sub-parser here
+ # because location_id of the package level may be
+ # empty, we have to go get the picking's one
+ "location_src": self.location(record.picking_id.location_id, **kw),
+ }
+ )
+ return data
+
+ def package_levels(self, records, **kw):
+ return [self.package_level(rec, **kw) for rec in records]
+
+ @property
+ def _package_level_parser(self):
+ return [
+ "id",
+ "is_done",
+ ("package_id:package", self._package_parser),
+ ("location_dest_id:location_dest", self._location_parser),
+ ]
+
def product(self, record, **kw):
return self._jsonify(record, self._product_parser, **kw)
diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py
new file mode 100644
index 0000000000..6777636dca
--- /dev/null
+++ b/shopfloor/actions/location_content_transfer_sorter.py
@@ -0,0 +1,56 @@
+from odoo.addons.component.core import Component
+
+
+class LocationContentTransferSorter(Component):
+
+ _name = "shopfloor.location.content.transfer.sorter"
+ _inherit = "shopfloor.process.action"
+ _usage = "location_content_transfer.sorter"
+
+ def __init__(self, work_context):
+ super().__init__(work_context)
+ self._pickings = self.env["stock.picking"].browse()
+ self._content = None
+
+ def feed_pickings(self, pickings):
+ self._pickings |= pickings
+
+ def move_lines(self):
+ return self._pickings.move_line_ids.filtered(
+ # lines without package level only (raw products)
+ lambda line: not line.package_level_id
+ and line.state not in ("cancel", "done")
+ )
+
+ def package_levels(self):
+ return self._pickings.package_level_ids.filtered(
+ lambda level: level.state not in ("cancel", "done")
+ )
+
+ @staticmethod
+ def _sort_key(content):
+ # content can be either a move line, either a package
+ # level
+ return (
+ # sort by similar destination
+ content.location_dest_id.complete_name,
+ # lines before packages (if we have raw products and packages, raw
+ # will be on top? wild guess)
+ 0 if content._name == "stock.move.line" else 1,
+ # to have a deterministic sort
+ content.id,
+ )
+
+ def sort(self):
+ content = [line for line in self.move_lines()] + [
+ level for level in self.package_levels()
+ ]
+ self._content = sorted(content, key=self._sort_key)
+
+ def __iter__(self):
+ if self._content is None:
+ self.sort()
+ return iter(self._content)
+
+ def __next__(self):
+ return next(iter(self))
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 143bb83e7b..db160ee3f4 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -299,6 +299,14 @@ def transfer_complete(self, picking):
"body": _("Transfer {} complete").format(picking.name),
}
+ def location_content_transfer_complete(self, location):
+ return {
+ "message_type": "success",
+ "body": _("Location Content Transfer from {} complete").format(
+ location.name
+ ),
+ }
+
def transfer_confirm_done(self):
return {
"message_type": "warning",
@@ -315,3 +323,9 @@ def transfer_no_qty_done(self):
"No quantity has been processed, unable to complete the transfer."
),
}
+
+ def recovered_previous_session(self):
+ return {
+ "message_type": "info",
+ "body": _("Recovered previous session."),
+ }
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index d8e98a9366..9effb63b7e 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -1,3 +1,5 @@
+from odoo import _
+
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -46,7 +48,7 @@ def _response_for_start(self, message=None):
"""Transition to the 'start' state"""
return self._response(next_state="start", message=message)
- def _response_for_scan_destination_all(self, location, message=None):
+ def _response_for_scan_destination_all(self, location, pickings=None, message=None):
"""Transition to the 'scan_destination_all' state
The client screen shows a summary of all the lines and packages
@@ -54,48 +56,94 @@ def _response_for_scan_destination_all(self, location, message=None):
"""
return self._response(
next_state="scan_destination_all",
- data=self._data_content_all_for_location(location),
+ data=self._data_content_all_for_location(location, pickings=pickings),
message=message,
)
- def _response_for_start_single(
- self, location, package_level=None, line=None, message=None
- ):
+ def _response_for_start_single(self, location, next_content, message=None):
"""Transition to the 'start_single' state
The client screen shows details of the package level or move line to move.
"""
- assert package_level or line
return self._response(
next_state="start_single",
- data=self._data_content_line_for_location(
- location, package_level=package_level, line=line
- ),
+ data=self._data_content_line_for_location(location, next_content),
message=message,
)
- def _response_for_scan_destination(
- self, location, package_level=None, line=None, message=None
- ):
+ def _response_for_scan_destination(self, location, next_content, message=None):
"""Transition to the 'start_single' state
The client screen shows details of the package level or move line to move.
"""
- assert package_level or line
return self._response(
next_state="scan_destination",
- data=self._data_content_line_for_location(
- location, package_level=package_level, line=line
- ),
+ data=self._data_content_line_for_location(location, next_content),
message=message,
)
- def _data_content_all_for_location(self, location):
- return {}
+ def _data_content_all_for_location(self, location, pickings=None):
+ if not pickings:
+ # TODO get pickings from location
+ raise NotImplementedError("to do: get pickings from location")
+ sorter = self.actions_for("location_content_transfer.sorter")
+ sorter.feed_pickings(pickings)
+ lines = sorter.move_lines()
+ package_levels = sorter.package_levels()
+ return {
+ "move_lines": self.data.move_lines(lines),
+ "package_levels": self.data.package_levels(package_levels),
+ }
- def _data_content_line_for_location(self, location, package_level=None, line=None):
- assert package_level or line
- return {}
+ def _data_content_line_for_location(self, location, next_content):
+ assert next_content._name in ("stock.move.line", "stock.package_level")
+ line_data = (
+ self.data.move_line(next_content)
+ if next_content._name == "stock.move.line"
+ else None
+ )
+ level_data = (
+ self.data.package_level(next_content)
+ if next_content._name == "stock.package_level"
+ else None
+ )
+ return {"move_line": line_data, "package_level": level_data}
+
+ def _router_single_or_all_destination(self, pickings, message=None):
+ location = pickings.mapped("location_id")
+ if len(pickings.mapped("move_line_ids.location_dest_id")) == 1:
+ return self._response_for_scan_destination_all(
+ location, pickings=pickings, message=message
+ )
+ else:
+ sorter = self.actions_for("location_content_transfer.sorter")
+ sorter.feed_pickings(pickings)
+ try:
+ next_content = next(sorter)
+ except StopIteration:
+ # TODO test
+ return self._response_for_start(
+ message=self.msg_store.location_content_transfer_complete(location)
+ )
+ return self._response_for_start_single(
+ location, next_content, message=message
+ )
+
+ def _domain_recover_pickings(self):
+ return [
+ ("user_id", "=", self.env.uid),
+ ("state", "in", ("assigned", "partially_available")),
+ ("picking_type_id", "in", self.picking_types.ids),
+ ]
+
+ def _search_recover_pickings(self):
+ candidate_pickings = self.env["stock.picking"].search(
+ self._domain_recover_pickings()
+ )
+ started_pickings = candidate_pickings.filtered(
+ lambda picking: any(line.qty_done for line in picking.move_line_ids)
+ )
+ return started_pickings
def start_or_recover(self):
"""Start a new session or recover an existing one
@@ -104,11 +152,24 @@ def start_or_recover(self):
and reopen the menu, we want to directly reopen the screens to choose
destinations. Otherwise, we go to the "start" state.
"""
- # TODO if we find any stock.picking != done with current user as user id
- # and with move lines having a qty_done > 0, in the current picking types,
- # reach start_single or scan_destination_all
+ started_pickings = self._search_recover_pickings()
+ if started_pickings:
+ return self._router_single_or_all_destination(
+ started_pickings, message=self.msg_store.recovered_previous_session()
+ )
return self._response_for_start()
+ def _find_location_move_lines_domain(self, location):
+ return [
+ ("location_id", "=", location.id),
+ ("qty_done", "=", 0),
+ ]
+
+ def _find_location_move_lines(self, location):
+ return self.env["stock.move.line"].search(
+ self._find_location_move_lines_domain(location)
+ )
+
def scan_location(self, barcode):
"""Scan start location
@@ -126,7 +187,34 @@ def scan_location(self, barcode):
levels have the same destination
* start_single: if any line or package level has a different destination
"""
- return self._response()
+ location = self.actions_for("search").location_from_scan(barcode)
+ if not location:
+ return self._response_for_start(message=self.msg_store.barcode_not_found())
+ move_lines = self._find_location_move_lines(location)
+ pickings = move_lines.mapped("picking_id")
+ picking_types = pickings.mapped("picking_type_id")
+
+ if len(picking_types) > 1:
+ return self._response_for_start(
+ message={
+ "message_type": "error",
+ "body": _("This location content can't be moved at once."),
+ }
+ )
+ if picking_types - self.picking_types:
+ return self._response_for_start(
+ message={
+ "message_type": "error",
+ "body": _("This location content can't be moved using this menu."),
+ }
+ )
+
+ for line in move_lines:
+ line.qty_done = line.product_uom_qty
+
+ pickings.user_id = self.env.uid
+
+ return self._router_single_or_all_destination(pickings)
def set_destination_all(self, location_id, barcode):
"""Scan destination location for all the moves of the location
@@ -374,12 +462,12 @@ def _states(self):
@property
def _schema_all(self):
- package_schema = self.schemas.package()
+ package_level_schema = self.schemas.package_level()
move_line_schema = self.schemas.move_line()
return {
# we'll display all the packages and move lines *without package
# levels*
- "packages": self.schemas._schema_list_of(package_schema),
+ "package_levels": self.schemas._schema_list_of(package_level_schema),
"move_lines": self.schemas._schema_list_of(move_line_schema),
}
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index f894f82250..c019d057e5 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -170,9 +170,8 @@ def picking_batch(self, with_pickings=False):
def package_level(self):
return {
"id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
+ "is_done": {"type": "boolean", "nullable": False, "required": True},
+ "package": {"type": "dict", "schema": self.package()},
"location_src": {"type": "dict", "schema": self.location()},
"location_dest": {"type": "dict", "schema": self.location()},
- "product": {"type": "dict", "schema": self.product()},
- "picking": {"type": "dict", "schema": self.picking()},
}
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 085f851a56..2306432b18 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -13,6 +13,7 @@ class SinglePackTransfer(Component):
def _data_after_package_scanned(self, package_level):
move_line = package_level.move_line_ids[0]
package = package_level.package_id
+ # TODO use data.package_level (but the "name" moves in "package.name")
return {
"id": package_level.id,
"name": package.name,
@@ -288,4 +289,12 @@ def validate(self):
@property
def _schema_for_package_level_details(self):
- return self.schemas.package_level()
+ # TODO use schemas.package_level (but the "name" moves in "package.name")
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "location_src": {"type": "dict", "schema": self.schemas.location()},
+ "location_dest": {"type": "dict", "schema": self.schemas.location()},
+ "product": {"type": "dict", "schema": self.schemas.product()},
+ "picking": {"type": "dict", "schema": self.schemas.picking()},
+ }
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index fb9f55a585..83efe92bcf 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -34,3 +34,4 @@
from . import test_delivery_list_stock_picking
from . import test_delivery_select
from . import test_location_content_transfer_base
+from . import test_location_content_transfer_start
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 1f83cde1eb..9fbd131bec 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -288,13 +288,15 @@ def _update_qty_in_location(
)
@classmethod
- def _fill_stock_for_moves(cls, moves, in_package=False, in_lot=False):
+ def _fill_stock_for_moves(
+ cls, moves, in_package=False, in_lot=False, location=False
+ ):
product_locations = {}
package = None
if in_package:
package = cls.env["stock.quant.package"].create({})
for move in moves:
- key = (move.product_id, move.location_id)
+ key = (move.product_id, location or move.location_id)
product_locations.setdefault(key, 0)
product_locations[key] += move.product_qty
for (product, location), qty in product_locations.items():
diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py
index f66e8be98f..c5ebe0569c 100644
--- a/shopfloor/tests/test_location_content_transfer_base.py
+++ b/shopfloor/tests/test_location_content_transfer_base.py
@@ -13,8 +13,64 @@ def setUpClassVars(cls, *args, **kwargs):
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
+ cls.content_loc = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Content Location",
+ "barcode": "Content",
+ "location_id": cls.picking_type.default_location_src_id.id,
+ }
+ )
+ )
def setUp(self):
super().setUp()
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="location_content_transfer")
+
+ def _simulate_pickings_selected(self, pickings):
+ """Create a state as if pickings has been selected
+
+ ... during a Location content transfer.
+
+ It means a user scanned the location with the pickings. They are:
+
+ * assigned to the user
+ * the qty_done of all their move lines is set to they reserved qty
+
+ """
+ pickings.user_id = self.env.uid
+ for line in pickings.mapped("move_line_ids"):
+ line.qty_done = line.product_uom_qty
+
+ def assert_response_start(self, response, message=None):
+ self.assert_response(response, next_state="start", message=message)
+
+ def assert_response_scan_destination_all(self, response, pickings, message=None):
+ # this code is repeated from the implementation, not great, but we
+ # mostly want to ensure the selection of pickings is right, and the
+ # data methods have their own tests
+ lines = pickings.move_line_ids.filtered(lambda line: not line.package_level_id)
+ package_levels = pickings.package_level_ids
+ self.assert_response(
+ response,
+ next_state="scan_destination_all",
+ data={
+ "move_lines": self.data.move_lines(lines),
+ "package_levels": self.data.package_levels(package_levels),
+ },
+ message=message,
+ )
+
+ def assert_response_start_single(self, response, pickings, message=None):
+ sorter = self.service.actions_for("location_content_transfer.sorter")
+ sorter.feed_pickings(pickings)
+ location = pickings.mapped("location_id")
+ self.assert_response(
+ response,
+ next_state="start_single",
+ data=self.service._data_content_line_for_location(location, next(sorter)),
+ message=message,
+ )
diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py
new file mode 100644
index 0000000000..378048a7bc
--- /dev/null
+++ b/shopfloor/tests/test_location_content_transfer_start.py
@@ -0,0 +1,174 @@
+from .test_location_content_transfer_base import LocationContentTransferCommonCase
+
+
+class LocationContentTransferStartCase(LocationContentTransferCommonCase):
+ """Tests for start state and recover
+
+ Endpoints:
+
+ * /start_or_recover
+ * /scan_location
+ """
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ products = cls.product_a + cls.product_b + cls.product_c + cls.product_d
+ for product in products:
+ cls.env["stock.putaway.rule"].sudo().create(
+ {
+ "product_id": product.id,
+ "location_in_id": cls.stock_location.id,
+ "location_out_id": cls.shelf1.id,
+ }
+ )
+
+ cls.picking1 = picking1 = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10)]
+ )
+ cls.picking2 = picking2 = cls._create_picking(
+ lines=[(cls.product_c, 10), (cls.product_d, 10)]
+ )
+ cls.pickings = picking1 | picking2
+ cls._fill_stock_for_moves(
+ picking1.move_lines, in_package=True, location=cls.content_loc
+ )
+ cls._fill_stock_for_moves(picking2.move_lines, location=cls.content_loc)
+ cls.pickings.action_assign()
+
+ def test_start_fresh(self):
+ """Start a fresh session when there is no transfer to recover"""
+ response = self.service.dispatch("start_or_recover", params={})
+ self.assert_response(response, next_state="start")
+
+ def test_start_recover_destination_all(self):
+ """Recover transfers, all move lines have the same destination"""
+ self._simulate_pickings_selected(self.pickings)
+ # all lines go to the same destination (shelf1)
+ self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 1)
+
+ response = self.service.dispatch("start_or_recover", params={})
+ self.assert_response_scan_destination_all(
+ response,
+ self.pickings,
+ message=self.service.msg_store.recovered_previous_session(),
+ )
+
+ def test_start_recover_destination_single(self):
+ """Recover transfers, at least one move line has a different destination"""
+ self._simulate_pickings_selected(self.pickings)
+ self.picking1.package_level_ids.location_dest_id = self.shelf2
+ # we have different destinations
+ self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 2)
+ response = self.service.dispatch("start_or_recover", params={})
+ self.assert_response_start_single(
+ response,
+ self.pickings,
+ message=self.service.msg_store.recovered_previous_session(),
+ )
+
+ def test_scan_location_not_found(self):
+ """Scan a location with content to transfer, barcode not found"""
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": "NOT_FOUND"}
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.barcode_not_found()
+ )
+
+ def test_scan_location_find_content_destination_all(self):
+ """Scan a location with content to transfer, all dest. identical"""
+ # all lines go to the same destination (shelf1)
+ self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 1)
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.content_loc.barcode}
+ )
+ self.assert_response_scan_destination_all(response, self.pickings)
+ self.assertRecordValues(
+ self.pickings, [{"user_id": self.env.uid}, {"user_id": self.env.uid}]
+ )
+ self.assertRecordValues(
+ self.pickings.move_line_ids,
+ [
+ {"qty_done": 10.0},
+ {"qty_done": 10.0},
+ {"qty_done": 10.0},
+ {"qty_done": 10.0},
+ ],
+ )
+ self.assertRecordValues(self.picking1.package_level_ids, [{"is_done": True}])
+
+ def test_scan_location_find_content_destination_single(self):
+ """Scan a location with content to transfer, different destinations"""
+ self.picking1.package_level_ids.location_dest_id = self.shelf2
+ # we have different destinations
+ self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 2)
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.content_loc.barcode}
+ )
+ self.assert_response_start_single(response, self.pickings)
+ self.assertRecordValues(
+ self.pickings, [{"user_id": self.env.uid}, {"user_id": self.env.uid}]
+ )
+ self.assertRecordValues(
+ self.pickings.move_line_ids,
+ [
+ {"qty_done": 10.0},
+ {"qty_done": 10.0},
+ {"qty_done": 10.0},
+ {"qty_done": 10.0},
+ ],
+ )
+ self.assertRecordValues(self.picking1.package_level_ids, [{"is_done": True}])
+
+ def test_scan_location_different_picking_type(self):
+ """Content has different picking types, can't move"""
+ picking_other_type = self._create_picking(
+ picking_type=self.wh.pick_type_id, lines=[(self.product_a, 10)]
+ )
+ self._fill_stock_for_moves(
+ picking_other_type.move_lines, location=self.content_loc
+ )
+ picking_other_type.action_assign()
+
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.content_loc.barcode}
+ )
+ self.assert_response_start(
+ response,
+ message={
+ "message_type": "error",
+ "body": "This location content can't be moved at once.",
+ },
+ )
+
+
+class LocationContentTransferStartSpecialCase(LocationContentTransferCommonCase):
+ """Tests for start state and recover (special cases without setup)
+
+ Endpoints:
+
+ * /start_or_recover
+ * /scan_location
+ """
+
+ def test_scan_location_wrong_picking_type(self):
+ """Content has different picking type than menu"""
+ picking = self._create_picking(
+ picking_type=self.wh.pick_type_id,
+ lines=[(self.product_a, 10), (self.product_b, 10)],
+ )
+ self._fill_stock_for_moves(
+ picking.move_lines, in_package=True, location=self.content_loc
+ )
+ picking.action_assign()
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.content_loc.barcode}
+ )
+ self.assert_response_start(
+ response,
+ message={
+ "message_type": "error",
+ "body": "This location content can't be moved using this menu.",
+ },
+ )
From b120c651cebe5191434a2c931c5ae0ec13bab6b8 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 19 Jun 2020 08:09:38 +0200
Subject: [PATCH 265/940] checkout: fix state change when picking not found
---
shopfloor/services/checkout.py | 58 +++++++++++++++++++++++++---------
1 file changed, 43 insertions(+), 15 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index d73ee56489..3f08861d8a 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -308,7 +308,9 @@ def scan_line(self, picking_id, barcode):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
search = self.actions_for("search")
@@ -457,7 +459,9 @@ def select_line(self, picking_id, package_id=None, move_line_id=None):
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
selection_lines = self._lines_to_pack(picking)
if not selection_lines:
@@ -475,7 +479,9 @@ def _change_line_qty(
):
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
move_lines = self.env["stock.move.line"].browse(move_line_ids).exists()
@@ -669,7 +675,9 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
search = self.actions_for("search")
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
@@ -718,7 +726,9 @@ def new_package(self, picking_id, selected_line_ids):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
return self._create_and_assign_new_packaging(picking, selected_lines)
@@ -734,7 +744,9 @@ def no_package(self, picking_id, selected_line_ids):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
selected_lines.write(
{"shopfloor_checkout_done": True, "result_package_id": False}
@@ -759,7 +771,9 @@ def list_dest_package(self, picking_id, selected_line_ids):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
return self._response_for_select_dest_package(picking, lines)
@@ -797,7 +811,9 @@ def scan_dest_package(self, picking_id, selected_line_ids, barcode):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
search = self.actions_for("search")
package = search.package_from_scan(barcode)
@@ -817,7 +833,9 @@ def set_dest_package(self, picking_id, selected_line_ids, package_id):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
package = self.env["stock.quant.package"].browse(package_id).exists()
return self._set_dest_package_from_selection(picking, lines, package)
@@ -830,7 +848,9 @@ def summary(self, picking_id):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
return self._response_for_summary(picking)
def _get_allowed_packaging(self):
@@ -848,7 +868,9 @@ def list_packaging(self, picking_id, package_id):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
package = self.env["stock.quant.package"].browse(package_id).exists()
packaging_list = self._get_allowed_packaging()
@@ -863,7 +885,9 @@ def set_packaging(self, picking_id, package_id, packaging_id):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
package = self.env["stock.quant.package"].browse(package_id).exists()
packaging = self.env["product.packaging"].browse(packaging_id).exists()
@@ -898,7 +922,9 @@ def cancel_line(self, picking_id, package_id=None, line_id=None):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
package = self.env["stock.quant.package"].browse(package_id).exists()
line = self.env["stock.move.line"].browse(line_id).exists()
@@ -941,7 +967,9 @@ def done(self, picking_id, confirmation=False):
"""
picking = self.env["stock.picking"].browse(picking_id)
if not picking.exists():
- return self._response_stock_picking_does_not_exist()
+ return self._response_for_select_document(
+ message=self.msg_store.stock_picking_not_found()
+ )
lines = picking.move_line_ids
if not confirmation:
if not all(line.qty_done == line.product_uom_qty for line in lines):
@@ -1167,7 +1195,7 @@ def _schema_stock_picking(self, lines_with_packaging=False):
{
"move_lines": self.schemas._schema_list_of(
self.schemas.move_line(with_packaging=lines_with_packaging)
- ),
+ )
}
)
return {"picking": self.schemas._schema_dict_of(schema, required=True)}
From bacd5c780f2aedbefee9b007aa197c527f6a4379 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 19 Jun 2020 10:53:14 +0200
Subject: [PATCH 266/940] location transfer: implement /set_destination_all
---
.../location_content_transfer_sorter.py | 1 +
.../services/location_content_transfer.py | 149 +++++++++++++++---
shopfloor/tests/__init__.py | 1 +
.../test_location_content_transfer_base.py | 23 ++-
...on_content_transfer_set_destination_all.py | 142 +++++++++++++++++
5 files changed, 292 insertions(+), 24 deletions(-)
create mode 100644 shopfloor/tests/test_location_content_transfer_set_destination_all.py
diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py
index 6777636dca..a79bc69242 100644
--- a/shopfloor/actions/location_content_transfer_sorter.py
+++ b/shopfloor/actions/location_content_transfer_sorter.py
@@ -32,6 +32,7 @@ def _sort_key(content):
# content can be either a move line, either a package
# level
return (
+ # TODO add postponed (need to be added to package_level)
# sort by similar destination
content.location_dest_id.complete_name,
# lines before packages (if we have raw products and packages, raw
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 9effb63b7e..51bc7ea5bc 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -9,6 +9,9 @@
# picking" scenario
+# TODO add picking and package content in package level?
+
+
class LocationContentTransfer(Component):
"""
Methods for the Location Content Transfer Process
@@ -48,7 +51,7 @@ def _response_for_start(self, message=None):
"""Transition to the 'start' state"""
return self._response(next_state="start", message=message)
- def _response_for_scan_destination_all(self, location, pickings=None, message=None):
+ def _response_for_scan_destination_all(self, pickings, message=None):
"""Transition to the 'scan_destination_all' state
The client screen shows a summary of all the lines and packages
@@ -56,7 +59,20 @@ def _response_for_scan_destination_all(self, location, pickings=None, message=No
"""
return self._response(
next_state="scan_destination_all",
- data=self._data_content_all_for_location(location, pickings=pickings),
+ data=self._data_content_all_for_location(pickings=pickings),
+ message=message,
+ )
+
+ def _response_for_confirm_scan_destination_all(self, pickings, message=None):
+ """Transition to the 'confirm_scan_destination_all' state
+
+ The client screen shows a summary of all the lines and packages
+ to move to a single destination. The user has to scan the destination
+ location a second time to validate the destination.
+ """
+ return self._response(
+ next_state="confirm_scan_destination_all",
+ data=self._data_content_all_for_location(pickings=pickings),
message=message,
)
@@ -72,7 +88,7 @@ def _response_for_start_single(self, location, next_content, message=None):
)
def _response_for_scan_destination(self, location, next_content, message=None):
- """Transition to the 'start_single' state
+ """Transition to the 'scan_destination' state
The client screen shows details of the package level or move line to move.
"""
@@ -82,7 +98,22 @@ def _response_for_scan_destination(self, location, next_content, message=None):
message=message,
)
- def _data_content_all_for_location(self, location, pickings=None):
+ def _response_for_confirm_scan_destination(
+ self, location, next_content, message=None
+ ):
+ """Transition to the 'confirm_scan_destination' state
+
+ The client screen shows details of the package level or move line to
+ move. The user has to scan the destination location a second time to
+ validate the destination.
+ """
+ return self._response(
+ next_state="confirm_scan_destination",
+ data=self._data_content_line_for_location(location, next_content),
+ message=message,
+ )
+
+ def _data_content_all_for_location(self, pickings):
if not pickings:
# TODO get pickings from location
raise NotImplementedError("to do: get pickings from location")
@@ -109,19 +140,24 @@ def _data_content_line_for_location(self, location, next_content):
)
return {"move_line": line_data, "package_level": level_data}
+ def _next_content(self, pickings):
+ sorter = self.actions_for("location_content_transfer.sorter")
+ sorter.feed_pickings(pickings)
+ try:
+ next_content = next(sorter)
+ except StopIteration:
+ # TODO set picking to done
+ return None
+ return next_content
+
def _router_single_or_all_destination(self, pickings, message=None):
location = pickings.mapped("location_id")
if len(pickings.mapped("move_line_ids.location_dest_id")) == 1:
- return self._response_for_scan_destination_all(
- location, pickings=pickings, message=message
- )
+ return self._response_for_scan_destination_all(pickings, message=message)
else:
- sorter = self.actions_for("location_content_transfer.sorter")
- sorter.feed_pickings(pickings)
- try:
- next_content = next(sorter)
- except StopIteration:
- # TODO test
+ next_content = self._next_content(pickings)
+ if not next_content:
+ # TODO test (no more lines)
return self._response_for_start(
message=self.msg_store.location_content_transfer_complete(location)
)
@@ -163,9 +199,11 @@ def _find_location_move_lines_domain(self, location):
return [
("location_id", "=", location.id),
("qty_done", "=", 0),
+ ("state", "in", ("assigned", "partially_available")),
]
def _find_location_move_lines(self, location):
+ """Find lines that potentially are to move in the location"""
return self.env["stock.move.line"].search(
self._find_location_move_lines_domain(location)
)
@@ -216,7 +254,28 @@ def scan_location(self, barcode):
return self._router_single_or_all_destination(pickings)
- def set_destination_all(self, location_id, barcode):
+ def _find_transfer_move_lines_domain(self, location):
+ return [
+ ("location_id", "=", location.id),
+ ("state", "in", ("assigned", "partially_available")),
+ ("qty_done", ">", 0),
+ # TODO check generated SQL
+ ("picking_id.user_id", "=", self.env.uid),
+ ]
+
+ def _find_transfer_move_lines(self, location):
+ """Find move lines currently being moved by the user"""
+ lines = self.env["stock.move.line"].search(
+ self._find_transfer_move_lines_domain(location)
+ )
+ return lines
+
+ def _set_destination_lines(self, pickings, move_lines, dest_location):
+ move_lines.location_dest_id = dest_location
+ move_lines.package_level_id.location_dest_id = dest_location
+ pickings.action_done()
+
+ def set_destination_all(self, location_id, barcode, confirmation=False):
"""Scan destination location for all the moves of the location
barcode is a stock.location for the destination
@@ -225,7 +284,38 @@ def set_destination_all(self, location_id, barcode):
* scan_destination_all: invalid destination or could not set moves to done
* start: moves are done
"""
- return self._response()
+ location = self.env["stock.location"].browse(location_id)
+ if not location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ move_lines = self._find_transfer_move_lines(location)
+ pickings = move_lines.mapped("picking_id")
+ scanned_location = self.actions_for("search").location_from_scan(barcode)
+ if not scanned_location:
+ return self._response_for_scan_destination_all(
+ pickings, message=self.msg_store.barcode_not_found()
+ )
+
+ if not scanned_location.is_sublocation_of(
+ self.picking_types.mapped("default_location_dest_id")
+ ):
+ return self._response_for_scan_destination_all(
+ pickings, message=self.msg_store.dest_location_not_allowed()
+ )
+ if not confirmation and not scanned_location.is_sublocation_of(
+ move_lines.mapped("location_dest_id")
+ ):
+ # the scanned location is valid (child of picking type's destination)
+ # but not the expected one: ask for confirmation
+ return self._response_for_confirm_scan_destination_all(pickings)
+
+ self._set_destination_lines(pickings, move_lines, scanned_location)
+
+ return self._response_for_start(
+ message={
+ "message_type": "success",
+ "body": _("Content transferred from {}.").format(location.name),
+ }
+ )
def go_to_single(self, location_id):
"""Ask the first move line or package level
@@ -264,7 +354,9 @@ def scan_line(self, location_id, move_line_id, barcode):
"""
return self._response()
- def set_destination_package(self, location_id, package_level_id, barcode):
+ def set_destination_package(
+ self, location_id, package_level_id, barcode, confirmation=False
+ ):
"""Scan destination location for package level
If the move has other move lines / package levels it has to be split
@@ -285,7 +377,9 @@ def set_destination_package(self, location_id, package_level_id, barcode):
"""
return self._response()
- def set_destination_line(self, location_id, move_line_id, quantity, barcode):
+ def set_destination_line(
+ self, location_id, move_line_id, quantity, barcode, confirmation=False
+ ):
"""Scan destination location for move line
If the quantity < qty of the line, split the move and reserve it.
@@ -381,6 +475,7 @@ def set_destination_all(self):
return {
"location_id": {"coerce": to_int, "required": True, "type": "integer"},
"barcode": {"required": True, "type": "string"},
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
}
def go_to_single(self):
@@ -405,6 +500,7 @@ def set_destination_package(self):
"location_id": {"coerce": to_int, "required": True, "type": "integer"},
"package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
"barcode": {"required": True, "type": "string"},
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
}
def set_destination_line(self):
@@ -413,6 +509,7 @@ def set_destination_line(self):
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
"quantity": {"coerce": to_float, "required": True, "type": "float"},
"barcode": {"required": True, "type": "string"},
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
}
def postpone_package(self):
@@ -456,8 +553,10 @@ def _states(self):
return {
"start": {},
"scan_destination_all": self._schema_all,
+ "confirm_scan_destination_all": self._schema_all,
"start_single": self._schema_single,
"scan_destination": self._schema_single,
+ "confirm_scan_destination": self._schema_single,
}
@property
@@ -493,7 +592,13 @@ def scan_location(self):
)
def set_destination_all(self):
- return self._response_schema(next_states={"start", "scan_destination_all"})
+ return self._response_schema(
+ next_states={
+ "start",
+ "scan_destination_all",
+ "confirm_scan_destination_all",
+ }
+ )
def go_to_single(self):
return self._response_schema(next_states={"start", "start_single"})
@@ -509,10 +614,14 @@ def scan_line(self):
)
def set_destination_package(self):
- return self._response_schema(next_states={"start_single", "scan_destination"})
+ return self._response_schema(
+ next_states={"start_single", "scan_destination", "confirm_scan_destination"}
+ )
def set_destination_line(self):
- return self._response_schema(next_states={"start_single", "scan_destination"})
+ return self._response_schema(
+ next_states={"start_single", "scan_destination", "confirm_scan_destination"}
+ )
def postpone_package(self):
return self._response_schema(next_states={"start_single"})
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 83efe92bcf..d368870c0a 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -35,3 +35,4 @@
from . import test_delivery_select
from . import test_location_content_transfer_base
from . import test_location_content_transfer_start
+from . import test_location_content_transfer_set_destination_all
diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py
index c5ebe0569c..078f40a6ca 100644
--- a/shopfloor/tests/test_location_content_transfer_base.py
+++ b/shopfloor/tests/test_location_content_transfer_base.py
@@ -30,7 +30,8 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="location_content_transfer")
- def _simulate_pickings_selected(self, pickings):
+ @classmethod
+ def _simulate_pickings_selected(cls, pickings):
"""Create a state as if pickings has been selected
... during a Location content transfer.
@@ -41,14 +42,16 @@ def _simulate_pickings_selected(self, pickings):
* the qty_done of all their move lines is set to they reserved qty
"""
- pickings.user_id = self.env.uid
+ pickings.user_id = cls.env.uid
for line in pickings.mapped("move_line_ids"):
line.qty_done = line.product_uom_qty
def assert_response_start(self, response, message=None):
self.assert_response(response, next_state="start", message=message)
- def assert_response_scan_destination_all(self, response, pickings, message=None):
+ def _assert_response_scan_destination_all(
+ self, state, response, pickings, message=None
+ ):
# this code is repeated from the implementation, not great, but we
# mostly want to ensure the selection of pickings is right, and the
# data methods have their own tests
@@ -56,7 +59,7 @@ def assert_response_scan_destination_all(self, response, pickings, message=None)
package_levels = pickings.package_level_ids
self.assert_response(
response,
- next_state="scan_destination_all",
+ next_state=state,
data={
"move_lines": self.data.move_lines(lines),
"package_levels": self.data.package_levels(package_levels),
@@ -64,6 +67,18 @@ def assert_response_scan_destination_all(self, response, pickings, message=None)
message=message,
)
+ def assert_response_scan_destination_all(self, response, pickings, message=None):
+ self._assert_response_scan_destination_all(
+ "scan_destination_all", response, pickings, message=message
+ )
+
+ def assert_response_confirm_scan_destination_all(
+ self, response, pickings, message=None
+ ):
+ self._assert_response_scan_destination_all(
+ "confirm_scan_destination_all", response, pickings, message=message
+ )
+
def assert_response_start_single(self, response, pickings, message=None):
sorter = self.service.actions_for("location_content_transfer.sorter")
sorter.feed_pickings(pickings)
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py
new file mode 100644
index 0000000000..be48916af0
--- /dev/null
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py
@@ -0,0 +1,142 @@
+from .test_location_content_transfer_base import LocationContentTransferCommonCase
+
+
+class LocationContentTransferSetDestinationAllCase(LocationContentTransferCommonCase):
+ """Tests for endpoint /set_destination_all"""
+
+ # TODO see what can be common
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ products = cls.product_a + cls.product_b + cls.product_c + cls.product_d
+ for product in products:
+ cls.env["stock.putaway.rule"].sudo().create(
+ {
+ "product_id": product.id,
+ "location_in_id": cls.stock_location.id,
+ "location_out_id": cls.shelf1.id,
+ }
+ )
+
+ cls.picking1 = picking1 = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10)]
+ )
+ cls.picking2 = picking2 = cls._create_picking(
+ lines=[(cls.product_c, 10), (cls.product_d, 10)]
+ )
+ cls.pickings = picking1 | picking2
+ cls._fill_stock_for_moves(
+ picking1.move_lines, in_package=True, location=cls.content_loc
+ )
+ cls._fill_stock_for_moves(picking2.move_lines, location=cls.content_loc)
+ cls.pickings.action_assign()
+ cls._simulate_pickings_selected(cls.pickings)
+
+ def assert_all_done(self, destination):
+ self.assertRecordValues(self.pickings, [{"state": "done"}, {"state": "done"}])
+ self.assertRecordValues(
+ self.pickings.move_line_ids,
+ [
+ {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id},
+ {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id},
+ {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id},
+ {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id},
+ ],
+ )
+ self.assertRecordValues(
+ self.picking1.package_level_ids,
+ [{"is_done": True, "state": "done", "location_dest_id": destination.id}],
+ )
+
+ def test_set_destination_all_dest_location_ok(self):
+ """Scanned destination location valid, moves set to done accepted"""
+ sub_shelf1 = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Sub Shelf 1",
+ "barcode": "subshelf1",
+ "location_id": self.shelf1.id,
+ }
+ )
+ )
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={"location_id": self.content_loc.id, "barcode": sub_shelf1.barcode},
+ )
+ self.assert_response_start(
+ response,
+ message={
+ "message_type": "success",
+ "body": "Content transferred from {}.".format(self.content_loc.name),
+ },
+ )
+ self.assert_all_done(sub_shelf1)
+
+ def test_set_destination_all_dest_location_not_found(self):
+ """Barcode scanned for destination location is not found"""
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={"location_id": self.content_loc.id, "barcode": "NOT_FOUND"},
+ )
+ self.assert_response_scan_destination_all(
+ response, self.pickings, message=self.service.msg_store.barcode_not_found()
+ )
+
+ def test_set_destination_all_dest_location_need_confirm(self):
+ """Scanned dest. location != child but in picking type location
+
+ So it needs confirmation.
+ """
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "location_id": self.content_loc.id,
+ # expected location was shelf1, but shelf2 is valid as still in the
+ # picking type's default dest location, ask confirmation (second scan)
+ # from the user
+ "barcode": self.shelf2.barcode,
+ },
+ )
+ self.assert_response_confirm_scan_destination_all(response, self.pickings)
+
+ def test_set_destination_all_dest_location_confirmation(self):
+ """Scanned dest. location != child but in picking type location: confirm
+
+ use the confirmation flag to confirm
+ """
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "location_id": self.content_loc.id,
+ # expected location was shelf1, but shelf2 is valid as still in the
+ # picking type's default dest location, ask confirmation (second scan)
+ # from the user
+ "barcode": self.shelf2.barcode,
+ "confirmation": True,
+ },
+ )
+ self.assert_response_start(
+ response,
+ message={
+ "message_type": "success",
+ "body": "Content transferred from {}.".format(self.content_loc.name),
+ },
+ )
+ self.assert_all_done(self.shelf2)
+
+ def test_set_destination_all_dest_location_invalid(self):
+ """The scanned destination location is not in the menu's picking types"""
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "location_id": self.content_loc.id,
+ "barcode": self.dispatch_location.barcode,
+ },
+ )
+ self.assert_response_scan_destination_all(
+ response,
+ self.pickings,
+ message=self.service.msg_store.dest_location_not_allowed(),
+ )
From 416170866dbd7ca04ae73cb7b197f62e04ff9416 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 19 Jun 2020 11:51:05 +0200
Subject: [PATCH 267/940] location transfer: implement /go_to_single
---
.../services/location_content_transfer.py | 26 ++++++++++---------
...on_content_transfer_set_destination_all.py | 14 +++++++++-
2 files changed, 27 insertions(+), 13 deletions(-)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 51bc7ea5bc..53843f03eb 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -76,11 +76,18 @@ def _response_for_confirm_scan_destination_all(self, pickings, message=None):
message=message,
)
- def _response_for_start_single(self, location, next_content, message=None):
+ def _response_for_start_single(self, pickings, message=None):
"""Transition to the 'start_single' state
The client screen shows details of the package level or move line to move.
"""
+ location = pickings.mapped("location_id")
+ next_content = self._next_content(pickings)
+ if not next_content:
+ # TODO test (no more lines)
+ return self._response_for_start(
+ message=self.msg_store.location_content_transfer_complete(location)
+ )
return self._response(
next_state="start_single",
data=self._data_content_line_for_location(location, next_content),
@@ -151,19 +158,10 @@ def _next_content(self, pickings):
return next_content
def _router_single_or_all_destination(self, pickings, message=None):
- location = pickings.mapped("location_id")
if len(pickings.mapped("move_line_ids.location_dest_id")) == 1:
return self._response_for_scan_destination_all(pickings, message=message)
else:
- next_content = self._next_content(pickings)
- if not next_content:
- # TODO test (no more lines)
- return self._response_for_start(
- message=self.msg_store.location_content_transfer_complete(location)
- )
- return self._response_for_start_single(
- location, next_content, message=message
- )
+ return self._response_for_start_single(pickings, message=message)
def _domain_recover_pickings(self):
return [
@@ -328,7 +326,11 @@ def go_to_single(self, location_id):
* start: no remaining lines in the location
* start_single: if any line or package level has a different destination
"""
- return self._response()
+ location = self.env["stock.location"].browse(location_id)
+ if not location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ move_lines = self._find_transfer_move_lines(location)
+ return self._response_for_start_single(move_lines.mapped("picking_id"))
def scan_package(self, location_id, package_level_id, barcode):
"""Scan a package level to move
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py
index be48916af0..022fb48e45 100644
--- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py
@@ -2,7 +2,12 @@
class LocationContentTransferSetDestinationAllCase(LocationContentTransferCommonCase):
- """Tests for endpoint /set_destination_all"""
+ """Tests for endpoint used from scan_destination_all
+
+ * /set_destination_all
+ * /go_to_single
+
+ """
# TODO see what can be common
@classmethod
@@ -140,3 +145,10 @@ def test_set_destination_all_dest_location_invalid(self):
self.pickings,
message=self.service.msg_store.dest_location_not_allowed(),
)
+
+ def test_go_to_single(self):
+ """User used to 'split by lines' button to process line per line"""
+ response = self.service.dispatch(
+ "go_to_single", params={"location_id": self.content_loc.id}
+ )
+ self.assert_response_start_single(response, self.pickings)
From 265f2821e4db92eec4c6eb648407d8822d01ebc0 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 19 Jun 2020 14:59:46 +0200
Subject: [PATCH 268/940] location transfer: implement /scan_package
---
.../services/location_content_transfer.py | 52 ++++-
shopfloor/tests/__init__.py | 1 +
.../test_location_content_transfer_base.py | 23 ++
.../test_location_content_transfer_single.py | 203 ++++++++++++++++++
4 files changed, 278 insertions(+), 1 deletion(-)
create mode 100644 shopfloor/tests/test_location_content_transfer_single.py
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 53843f03eb..643e868da5 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -342,7 +342,57 @@ def scan_package(self, location_id, package_level_id, barcode):
* start_single: barcode not found, ...
* scan_destination: the barcode matches
"""
- return self._response()
+ location = self.env["stock.location"].browse(location_id)
+ if not location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ package_level = self.env["stock.package_level"].browse(package_level_id)
+ if not package_level.exists():
+ move_lines = self._find_transfer_move_lines(location)
+ return self._response_for_start_single(
+ move_lines.mapped("picking_id"),
+ message=self.msg_store.record_not_found(),
+ )
+
+ search = self.actions_for("search")
+ package = search.package_from_scan(barcode)
+ if package and package_level.package_id == package:
+ return self._response_for_scan_destination(location, package_level)
+
+ move_lines = self._find_transfer_move_lines(location)
+ package_move_lines = package_level.move_line_ids
+ other_move_lines = move_lines - package_move_lines
+
+ product = search.product_from_scan(barcode)
+ # Normally the user scan the barcode of the package. But if they scan the
+ # product and we can be sure it's the correct package, it's tolerated.
+ if product and product in package_move_lines.mapped("product_id"):
+ if product in other_move_lines.mapped("product_id") or product.tracking in (
+ "lot",
+ "serial",
+ ):
+ # When the product exists in other move lines as raw products
+ # or part of another package, we can't be sure they scanned
+ # the correct package, so ask to scan the package.
+ return self._response_for_start_single(
+ move_lines.mapped("picking_id"),
+ message={"message_type": "error", "body": _("Scan the package")},
+ )
+ else:
+ return self._response_for_scan_destination(location, package_level)
+
+ lot = search.lot_from_scan(barcode)
+ if lot and lot in package_move_lines.mapped("lot_id"):
+ if lot in other_move_lines.mapped("lot_id"):
+ return self._response_for_start_single(
+ move_lines.mapped("picking_id"),
+ message={"message_type": "error", "body": _("Scan the package")},
+ )
+ else:
+ return self._response_for_scan_destination(location, package_level)
+
+ return self._response_for_start_single(
+ move_lines.mapped("picking_id"), message=self.msg_store.barcode_not_found()
+ )
def scan_line(self, location_id, move_line_id, barcode):
"""Scan a move line to move
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index d368870c0a..4584cde8dc 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -36,3 +36,4 @@
from . import test_location_content_transfer_base
from . import test_location_content_transfer_start
from . import test_location_content_transfer_set_destination_all
+from . import test_location_content_transfer_single
diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py
index 078f40a6ca..33010fe966 100644
--- a/shopfloor/tests/test_location_content_transfer_base.py
+++ b/shopfloor/tests/test_location_content_transfer_base.py
@@ -89,3 +89,26 @@ def assert_response_start_single(self, response, pickings, message=None):
data=self.service._data_content_line_for_location(location, next(sorter)),
message=message,
)
+
+ def _assert_response_scan_destination(
+ self, state, response, next_content, message=None
+ ):
+ location = next_content.location_id
+ self.assert_response(
+ response,
+ next_state=state,
+ data=self.service._data_content_line_for_location(location, next_content),
+ message=message,
+ )
+
+ def assert_response_scan_destination(self, response, next_content, message=None):
+ self._assert_response_scan_destination(
+ "scan_destination", response, next_content, message=message
+ )
+
+ def assert_response_confirm_scan_destination(
+ self, response, next_content, message=None
+ ):
+ self._assert_response_scan_destination(
+ "confirm_scan_destination", response, next_content, message=message
+ )
diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py
new file mode 100644
index 0000000000..ef437e7969
--- /dev/null
+++ b/shopfloor/tests/test_location_content_transfer_single.py
@@ -0,0 +1,203 @@
+from .test_location_content_transfer_base import LocationContentTransferCommonCase
+
+
+class LocationContentTransferSingleCase(LocationContentTransferCommonCase):
+ """Tests for endpoint used from state start_single
+
+ * /scan_package
+ * /scan_line
+
+ """
+
+ # TODO common with set_destination_all?
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ products = cls.product_a + cls.product_b + cls.product_c + cls.product_d
+ for product in products:
+ cls.env["stock.putaway.rule"].sudo().create(
+ {
+ "product_id": product.id,
+ "location_in_id": cls.stock_location.id,
+ "location_out_id": cls.shelf1.id,
+ }
+ )
+
+ cls.product_d.tracking = "lot"
+ cls.picking1 = picking1 = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10)]
+ )
+ cls.picking2 = picking2 = cls._create_picking(
+ lines=[(cls.product_c, 10), (cls.product_d, 10)]
+ )
+ cls.pickings = picking1 | picking2
+ cls._fill_stock_for_moves(
+ picking1.move_lines, in_package=True, location=cls.content_loc
+ )
+ cls.product_d_lot = cls.env["stock.production.lot"].create(
+ {"product_id": cls.product_d.id, "company_id": cls.env.company.id}
+ )
+ cls._fill_stock_for_moves(picking2.move_lines[0], location=cls.content_loc)
+ cls._fill_stock_for_moves(
+ picking2.move_lines[1], location=cls.content_loc, in_lot=cls.product_d_lot
+ )
+ cls.pickings.action_assign()
+ cls._simulate_pickings_selected(cls.pickings)
+
+ def _test_scan_package_ok(self, barcode):
+ package_level = self.picking1.move_line_ids.package_level_id
+ response = self.service.dispatch(
+ "scan_package",
+ params={
+ "location_id": self.content_loc.id,
+ "package_level_id": package_level.id,
+ "barcode": barcode,
+ },
+ )
+ self.assert_response_scan_destination(response, package_level)
+
+ def test_scan_package_package_ok(self):
+ package_level = self.picking1.move_line_ids.package_level_id
+ self._test_scan_package_ok(package_level.package_id.name)
+
+ def test_scan_package_barcode_not_found(self):
+ package_level = self.picking1.move_line_ids.package_level_id
+ response = self.service.dispatch(
+ "scan_package",
+ params={
+ "location_id": self.content_loc.id,
+ "package_level_id": package_level.id,
+ "barcode": "NOT_FOUND",
+ },
+ )
+ self.assert_response_start_single(
+ response, self.pickings, message=self.service.msg_store.barcode_not_found()
+ )
+
+ def test_scan_package_product_ok(self):
+ # product_a is in the package and anywhere else so it's
+ # accepted to check we scanned the correct package
+ self._test_scan_package_ok(self.product_a.barcode)
+
+ def test_scan_package_product_packaging_ok(self):
+ # product_a is in the package and anywhere else so it's
+ # accepted to check we scanned the correct package
+ self._test_scan_package_ok(self.product_a.packaging_ids[0].barcode)
+
+ def test_scan_package_lot_ok(self):
+ package_level = self.picking1.move_line_ids.package_level_id
+ line_product_a = package_level.move_line_ids[0]
+ self.product_a.tracking = "lot"
+ line_product_a.lot_id = self.env["stock.production.lot"].create(
+ {"product_id": self.product_a.id, "company_id": self.env.company.id}
+ )
+ # lot of product_a is in the package and anywhere else so it's
+ # accepted to check we scanned the correct package
+ self._test_scan_package_ok(line_product_a.lot_id.name)
+
+ def _test_scan_package_nok(self, pickings, barcode, message):
+ package_level = self.picking1.move_line_ids.package_level_id
+ response = self.service.dispatch(
+ "scan_package",
+ params={
+ "location_id": self.content_loc.id,
+ "package_level_id": package_level.id,
+ "barcode": self.product_a.barcode,
+ },
+ )
+ self.assert_response_start_single(response, pickings, message=message)
+
+ def test_scan_package_product_nok_different_package(self):
+ # add another picking with a package with product a,
+ # if we scan product A, we can't know for which package it is
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(
+ picking.move_lines, in_package=True, location=self.content_loc
+ )
+ picking.action_assign()
+ self._simulate_pickings_selected(picking)
+ self._test_scan_package_nok(
+ self.pickings | picking,
+ self.product_a.barcode,
+ {"message_type": "error", "body": "Scan the package"},
+ )
+
+ def test_scan_package_product_nok_different_line(self):
+ # add another picking with a raw line with product a,
+ # if we scan product A, we can't know which line/package we want
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(picking.move_lines, location=self.content_loc)
+ picking.action_assign()
+ self._simulate_pickings_selected(picking)
+ self._test_scan_package_nok(
+ self.pickings | picking,
+ self.product_a.barcode,
+ {"message_type": "error", "body": "Scan the package"},
+ )
+
+ def test_scan_package_product_nok_product_tracked(self):
+ # we scan product_a's barcode but it's tracked by lot
+ self.product_a.tracking = "lot"
+ self._test_scan_package_nok(
+ self.pickings,
+ self.product_a.barcode,
+ {"message_type": "error", "body": "Scan the package"},
+ )
+
+ def test_scan_package_lot_nok_different_package(self):
+ # add another picking with a package with the lot used in our package,
+ # if we scan the lot, we can't know for which package it is
+ package_level = self.picking1.move_line_ids.package_level_id
+ line_product_a = package_level.move_line_ids[0]
+ self.product_a.tracking = "lot"
+ line_product_a.lot_id = lot = self.env["stock.production.lot"].create(
+ {"product_id": self.product_a.id, "company_id": self.env.company.id}
+ )
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(
+ picking.move_lines, in_package=True, in_lot=lot, location=self.content_loc
+ )
+ picking.action_assign()
+ self._simulate_pickings_selected(picking)
+ self._test_scan_package_nok(
+ self.pickings | picking,
+ self.product_a.barcode,
+ {"message_type": "error", "body": "Scan the package"},
+ )
+
+ def test_scan_package_lot_nok_different_line(self):
+ # add another picking with a raw line with a lot used in our package,
+ # if we scan the lot, we can't know which line/package we want
+ package_level = self.picking1.move_line_ids.package_level_id
+ line_product_a = package_level.move_line_ids[0]
+ self.product_a.tracking = "lot"
+ line_product_a.lot_id = lot = self.env["stock.production.lot"].create(
+ {"product_id": self.product_a.id, "company_id": self.env.company.id}
+ )
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(
+ picking.move_lines, in_lot=lot, location=self.content_loc
+ )
+ picking.action_assign()
+ self._simulate_pickings_selected(picking)
+ self._test_scan_package_nok(
+ self.pickings | picking,
+ self.product_a.barcode,
+ {"message_type": "error", "body": "Scan the package"},
+ )
+
+ def test_scan_package_package_level_not_exists(self):
+ package_level = self.picking1.move_line_ids.package_level_id
+ package_level_id = package_level.id
+ package_level.unlink()
+ response = self.service.dispatch(
+ "scan_package",
+ params={
+ "location_id": self.content_loc.id,
+ "package_level_id": package_level_id,
+ "barcode": self.product_a.barcode,
+ },
+ )
+ self.assert_response_start_single(
+ response, self.pickings, message=self.service.msg_store.record_not_found()
+ )
From 3d06df5da0cd851f1b6bf9872ebe05c5fe865a0f Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 22 Jun 2020 12:13:57 +0200
Subject: [PATCH 269/940] location transfer: implement /scan_line
---
.../services/location_content_transfer.py | 32 +++++++-
shopfloor/tests/common.py | 2 +-
.../test_location_content_transfer_single.py | 74 ++++++++++++++++++-
3 files changed, 105 insertions(+), 3 deletions(-)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 643e868da5..21b858477a 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -404,7 +404,37 @@ def scan_line(self, location_id, move_line_id, barcode):
* start_single: barcode not found, ...
* scan_destination: the barcode matches
"""
- return self._response()
+ location = self.env["stock.location"].browse(location_id)
+ if not location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ move_line = self.env["stock.move.line"].browse(move_line_id)
+ if not move_line.exists():
+ move_lines = self._find_transfer_move_lines(location)
+ return self._response_for_start_single(
+ move_lines.mapped("picking_id"),
+ message=self.msg_store.record_not_found(),
+ )
+
+ search = self.actions_for("search")
+ product = search.product_from_scan(barcode)
+ if product and product == move_line.product_id:
+ if product.tracking in ("lot", "serial"):
+ move_lines = self._find_transfer_move_lines(location)
+ return self._response_for_start_single(
+ move_lines.mapped("picking_id"),
+ message=self.msg_store.scan_lot_on_product_tracked_by_lot(),
+ )
+ else:
+ return self._response_for_scan_destination(location, move_line)
+
+ lot = search.lot_from_scan(barcode)
+ if lot and lot == move_line.lot_id:
+ return self._response_for_scan_destination(location, move_line)
+
+ move_lines = self._find_transfer_move_lines(location)
+ return self._response_for_start_single(
+ move_lines.mapped("picking_id"), message=self.msg_store.barcode_not_found()
+ )
def set_destination_package(
self, location_id, package_level_id, barcode, confirmation=False
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 9fbd131bec..b55dc7f269 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -202,7 +202,7 @@ def setUpClassBaseData(cls):
.create(
{
"name": "Box",
- "product_id": cls.product_b.id,
+ "product_id": cls.product_c.id,
"barcode": "ProductCBox",
}
)
diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py
index ef437e7969..d5b89bcd69 100644
--- a/shopfloor/tests/test_location_content_transfer_single.py
+++ b/shopfloor/tests/test_location_content_transfer_single.py
@@ -102,7 +102,7 @@ def _test_scan_package_nok(self, pickings, barcode, message):
params={
"location_id": self.content_loc.id,
"package_level_id": package_level.id,
- "barcode": self.product_a.barcode,
+ "barcode": barcode,
},
)
self.assert_response_start_single(response, pickings, message=message)
@@ -201,3 +201,75 @@ def test_scan_package_package_level_not_exists(self):
self.assert_response_start_single(
response, self.pickings, message=self.service.msg_store.record_not_found()
)
+
+ def _test_scan_line_ok(self, move_line, barcode):
+ response = self.service.dispatch(
+ "scan_line",
+ params={
+ "location_id": self.content_loc.id,
+ "move_line_id": move_line.id,
+ "barcode": barcode,
+ },
+ )
+ self.assert_response_scan_destination(response, move_line)
+
+ def test_scan_line_product_ok(self):
+ move_line = self.picking2.move_line_ids[0]
+ # check we selected the good line
+ self.assertEqual(move_line.product_id, self.product_c)
+ self._test_scan_line_ok(move_line, self.product_c.barcode)
+
+ def test_scan_line_product_packaging_ok(self):
+ move_line = self.picking2.move_line_ids[0]
+ # check we selected the good line
+ self.assertEqual(move_line.product_id, self.product_c)
+ self._test_scan_line_ok(move_line, self.product_c.packaging_ids[0].barcode)
+
+ def test_scan_line_lot_ok(self):
+ move_line = self.picking2.move_line_ids[1]
+ # check we selected the good line (the one with a lot)
+ self.assertEqual(move_line.product_id, self.product_d)
+ self._test_scan_line_ok(move_line, self.product_d_lot.name)
+
+ def _test_scan_line_nok(self, pickings, move_line_id, barcode, message):
+ response = self.service.dispatch(
+ "scan_line",
+ params={
+ "location_id": self.content_loc.id,
+ "move_line_id": move_line_id,
+ "barcode": barcode,
+ },
+ )
+ self.assert_response_start_single(response, pickings, message=message)
+
+ def test_scan_line_product_nok_product_tracked(self):
+ # we scan product_d's barcode but it's tracked by lot
+ move_line = self.picking2.move_line_ids[1]
+ # check we selected the good line (the one with a lot)
+ self.assertEqual(move_line.product_id, self.product_d)
+ self._test_scan_line_nok(
+ self.pickings,
+ move_line.id,
+ self.product_d.barcode,
+ self.service.msg_store.scan_lot_on_product_tracked_by_lot(),
+ )
+
+ def test_scan_line_barcode_not_found(self):
+ move_line = self.picking2.move_line_ids[0]
+ self._test_scan_line_nok(
+ self.pickings,
+ move_line.id,
+ "NOT_FOUND",
+ self.service.msg_store.barcode_not_found(),
+ )
+
+ def test_scan_line_move_line_not_exists(self):
+ move_line = self.picking2.move_line_ids[0]
+ move_line_id = move_line.id
+ move_line.unlink()
+ self._test_scan_line_nok(
+ self.pickings,
+ move_line_id,
+ "NOT_FOUND",
+ self.service.msg_store.record_not_found(),
+ )
From c6c084e62e85124f247e2b5b6b20607194f378ab Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 23 Jun 2020 13:29:15 +0200
Subject: [PATCH 270/940] location transfer: Add a bit of docstring for
/scan_location
---
shopfloor/services/location_content_transfer.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 21b858477a..76325bf609 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -214,6 +214,12 @@ def scan_location(self, barcode):
All the move lines and package levels must have the same picking type.
+ If the scanned location has no move lines, new move lines to move the
+ whole content of the location are created if:
+
+ * the menu has the option "Allow to create move(s)"
+ * the menu is linked to only one picking type.
+
When move lines and package levels have different destinations, the
first line without package level or package level is sent to the client.
From 68868c8123b94e505fabde0fbe65dbba743ca09c Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 24 Jun 2020 10:01:34 +0200
Subject: [PATCH 271/940] Add todo
---
shopfloor/services/location_content_transfer.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 76325bf609..8d0ffca722 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -233,6 +233,9 @@ def scan_location(self, barcode):
if not location:
return self._response_for_start(message=self.msg_store.barcode_not_found())
move_lines = self._find_location_move_lines(location)
+ # TODO: Add creation of move lines and package levels when empty, see
+ # single_pack_transfer.py for creation of package levels (but quants
+ # without packs need to be created as move lines too here)
pickings = move_lines.mapped("picking_id")
picking_types = pickings.mapped("picking_type_id")
From 2a0d663fce413de51db50975ce92eec334ed454e Mon Sep 17 00:00:00 2001
From: sebalix
Date: Tue, 30 Jun 2020 18:16:13 +0200
Subject: [PATCH 272/940] location transfer: improve /scan_location by creating
moves if needed
---
shopfloor/actions/message.py | 12 ++++
shopfloor/demo/shopfloor_menu_demo.xml | 1 +
.../services/location_content_transfer.py | 55 +++++++++++++++++--
.../test_location_content_transfer_start.py | 51 +++++++++++++++++
4 files changed, 113 insertions(+), 6 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index db160ee3f4..c497b46924 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -329,3 +329,15 @@ def recovered_previous_session(self):
"message_type": "info",
"body": _("Recovered previous session."),
}
+
+ def no_lines_to_process(self):
+ return {
+ "message_type": "info",
+ "body": _("No lines to process."),
+ }
+
+ def location_empty(self, location):
+ return {
+ "message_type": "info",
+ "body": _("Location {} empty").format(location.name),
+ }
diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml
index 2880a74a80..4929c516c0 100644
--- a/shopfloor/demo/shopfloor_menu_demo.xml
+++ b/shopfloor/demo/shopfloor_menu_demo.xml
@@ -39,6 +39,7 @@
+
@@ -42,6 +89,6 @@
Menus
shopfloor.menu
ir.actions.act_window
- tree
+ tree,form
From 3e86812cf387e9780c11f0b9ea853510a1ef5915 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 16 Jul 2020 16:17:38 +0200
Subject: [PATCH 288/940] Add shopfloor automatic creation of picking batch
When a user uses the "Get Work" button on the barcode device, if no transfer
batch is available, it automatically creates a new batch for the user.
---
shopfloor/readme/CONTRIBUTORS.rst | 1 +
shopfloor/services/cluster_picking.py | 1 -
shopfloor/views/shopfloor_menu.xml | 10 ++++++----
3 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/shopfloor/readme/CONTRIBUTORS.rst b/shopfloor/readme/CONTRIBUTORS.rst
index 7a55e5ba99..6fc648282a 100644
--- a/shopfloor/readme/CONTRIBUTORS.rst
+++ b/shopfloor/readme/CONTRIBUTORS.rst
@@ -1,3 +1,4 @@
* Alexandre Fayolle
+* Guewen Baconnier
ADD YOURSELF
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 88fe45175d..c7dba785a3 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -239,7 +239,6 @@ def _batch_picking_filter(self, picking):
"cancel",
)
- # TODO this may be used in other scenarios? if so, extract
def _select_a_picking_batch(self, batches):
# look for in progress + assigned to self first
candidates = batches.filtered(
diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml
index 177b271a31..149d4cea7b 100644
--- a/shopfloor/views/shopfloor_menu.xml
+++ b/shopfloor/views/shopfloor_menu.xml
@@ -57,11 +57,13 @@
/>
-
-
+ >
+
+
+
From ece616b115654986d1f7ded08bfc984fb1cf653a Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 13 Jul 2020 15:51:50 +0200
Subject: [PATCH 289/940] cluster picking: lock all the lines before set
destination
At the beginning of the endpoint that writes the destination of lines
moved by a package, lock them. This is mostly to have an entrypoint for
the module shopfloor_checkout_sync:
In the general scenario, the lines are moved by the same bin package,
they should be moved by the same operator, so we should never have any
concurrency issue.
However, when we use the synchronization of destinations
(shopfloor_checkout_sync), as soon as we scan a destination for the
first line, it updates the destination of all the other move lines
that reach the same operation type for an order, so it might change
the destination of another operator's line. At the beginning of the
endpoint, lock the line to ensure 2 operators won't synchronize
different destinations (I would expect to have a transaction rollback
error and a rollback anyway).
---
shopfloor/services/cluster_picking.py | 20 +++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index c7dba785a3..179179a6df 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1139,12 +1139,12 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
if not confirmation:
return self._response_for_confirm_unload_all(batch)
- self._unload_set_destination_on_lines(lines, scanned_location)
+ self._unload_write_destination_on_lines(lines, scanned_location)
completion_info = self.actions_for("completion.info")
completion_info_popup = completion_info.popup(lines)
return self._unload_end(batch, completion_info_popup=completion_info_popup)
- def _unload_set_destination_on_lines(self, lines, location):
+ def _unload_write_destination_on_lines(self, lines, location):
lines.write({"shopfloor_unloaded": True, "location_dest_id": location.id})
for line in lines:
# We set the picking to done only when the last line is
@@ -1259,6 +1259,20 @@ def unload_scan_destination(
if not lines:
return self._unload_end(batch)
+ return self._unload_scan_destination_lines(
+ batch, package, lines, barcode, confirmation=confirmation
+ )
+
+ def _unload_scan_destination_lock_lines(self, lines):
+ """Lock move lines"""
+ sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE" % lines._table
+ self.env.cr.execute(sql, (tuple(lines.ids),), log_exceptions=False)
+
+ def _unload_scan_destination_lines(
+ self, batch, package, lines, barcode, confirmation=False
+ ):
+ # Lock move lines that will be updated
+ self._unload_scan_destination_lock_lines(lines)
first_line = fields.first(lines)
picking_type = fields.first(batch.picking_ids).picking_type_id
scanned_location = self.actions_for("search").location_from_scan(barcode)
@@ -1277,7 +1291,7 @@ def unload_scan_destination(
if not confirmation:
return self._response_for_confirm_unload_set_destination(batch, package)
- self._unload_set_destination_on_lines(lines, scanned_location)
+ self._unload_write_destination_on_lines(lines, scanned_location)
completion_info = self.actions_for("completion.info")
completion_info_popup = completion_info.popup(lines)
From b5adf8b81c5da32f005c36c79dc5537c9b5e4883 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 23 Jul 2020 09:58:02 +0200
Subject: [PATCH 290/940] cluster picking: sort moves by priority
Priorities goes from "1" (Normal) to "3" (Very Urgent).
We want to pick very urgent first. The priority field may be empty,
ensure they have a normal priority if it happens.
---
shopfloor/services/cluster_picking.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 179179a6df..f805e05819 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -322,6 +322,7 @@ def _sort_key_lines(line):
line.shopfloor_postponed,
line.location_id.shopfloor_picking_sequence,
line.location_id.name,
+ -int(line.move_id.priority or 1),
line.move_id.sequence,
line.move_id.id,
line.id,
From a57444d9e089588ab23060cccd5b23732268fad7 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 23 Jul 2020 12:52:57 +0200
Subject: [PATCH 291/940] Change location picking sequence to a char
The rationale being:
* it can be composed of existing fields such as corridor, rack, side,
level (e.g. 04-006-L-02)
* adding a new shelf makes it easier to insert in such pattern than in a
sequence
---
shopfloor/models/stock_location.py | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
index 9bd822d152..53eb9eb88b 100644
--- a/shopfloor/models/stock_location.py
+++ b/shopfloor/models/stock_location.py
@@ -4,10 +4,13 @@
class StockLocation(models.Model):
_inherit = "stock.location"
- shopfloor_picking_sequence = fields.Integer(
+ shopfloor_picking_sequence = fields.Char(
string="Shopfloor Picking Sequence",
- default=0,
- help="The picking done in Shopfloor scenarios will respect this order.",
+ help="The picking done in Shopfloor scenarios will respect this order. "
+ "The sequence is a char so it can be composed of fields such as "
+ "'corridor-rack-side-level'. Pay attention to the padding "
+ "('09' is before '19', '9' is not). It is recommended to use an"
+ " Export then an Import to populate this field using a spreadsheet.",
)
source_move_line_ids = fields.One2many(
comodel_name="stock.move.line", inverse_name="location_id", readonly=True
From b2c7044505209d9011a71b1205c88c8472b2822d Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 24 Jun 2020 16:00:41 +0200
Subject: [PATCH 292/940] zone picking: add skeleton of the service
---
shopfloor/actions/data.py | 11 +
shopfloor/demo/shopfloor_menu_demo.xml | 9 +
shopfloor/demo/stock_picking_type_demo.xml | 15 +
shopfloor/models/shopfloor_menu.py | 1 +
shopfloor/models/stock_move_line.py | 1 +
shopfloor/services/__init__.py | 1 +
shopfloor/services/cluster_picking.py | 5 +-
shopfloor/services/schema.py | 3 +
shopfloor/services/zone_picking.py | 706 +++++++++++++++++++++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_zone_picking_base.py | 20 +
11 files changed, 770 insertions(+), 3 deletions(-)
create mode 100644 shopfloor/services/zone_picking.py
create mode 100644 shopfloor/tests/test_zone_picking_base.py
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index b5f3e30622..2ff2cbdd2b 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -215,3 +215,14 @@ def picking_batches(self, record, with_pickings=False, **kw):
@property
def _picking_batch_parser(self):
return ["id", "name", "picking_count", "move_line_count", "total_weight:weight"]
+
+ def picking_type(self, record, **kw):
+ parser = self._picking_type_parser
+ return self._jsonify(record, parser, **kw)
+
+ def picking_types(self, record, **kw):
+ return self.picking_type(record, multi=True)
+
+ @property
+ def _picking_type_parser(self):
+ return ["id", "name"]
diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml
index 4929c516c0..036a20b724 100644
--- a/shopfloor/demo/shopfloor_menu_demo.xml
+++ b/shopfloor/demo/shopfloor_menu_demo.xml
@@ -9,6 +9,15 @@
eval="[(4, ref('shopfloor.picking_type_single_pallet_transfer_demo'))]"
/>
+
+
+ Zone Picking
+ ZPI
+
+
+
+
+
+
+
+
+ internal
+
+
+
Cluster Picking
CPI
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index 98d503229f..f0385f5717 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -38,6 +38,7 @@ def _selection_scenario(self):
return [
# these must match a REST service's '_usage'
("single_pack_transfer", "Single Pack Transfer"),
+ ("zone_picking", "Zone Picking"),
("cluster_picking", "Cluster Picking"),
("checkout", "Checkout/Packing"),
("delivery", "Delivery"),
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index 29c96da23d..7bb68b899c 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -13,6 +13,7 @@ class StockMoveLine(models.Model):
"Indicates if a the move has been postponed in a barcode scenario.",
)
shopfloor_checkout_done = fields.Boolean(default=False)
+ shopfloor_user_id = fields.Many2one(comodel_name="res.users", index=True)
# we search lines based on their location in some workflows
location_id = fields.Many2one(index=True)
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 09470e83ee..166173654b 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -12,6 +12,7 @@
# process services
from . import checkout
+from . import zone_picking
from . import cluster_picking
from . import delivery
from . import location_content_transfer
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index f805e05819..b065ad02f1 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -666,9 +666,8 @@ def _are_all_dest_location_same(self, batch):
def prepare_unload(self, picking_batch_id):
"""Initiate the unloading phase of the scenario
- If the destination of all the move lines still to unload is the same,
- Everytime this method is called, it resets the flag according to the
- condition above.
+ It goes to different screens depending if all the move lines have
+ the same destination or not.
Transitions:
* unload_all: when all lines go to the same destination
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index ac610977ad..f686a61695 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -164,3 +164,6 @@ def package_level(self):
"product": self._schema_dict_of(self.product()),
"quantity": {"type": "float", "required": True},
}
+
+ def picking_type(self):
+ return self._simple_record()
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
new file mode 100644
index 0000000000..99172cfdd7
--- /dev/null
+++ b/shopfloor/services/zone_picking.py
@@ -0,0 +1,706 @@
+from odoo.addons.base_rest.components.service import to_bool, to_int
+from odoo.addons.component.core import Component
+
+from .service import to_float
+
+
+class ZonePicking(Component):
+ """
+ Methods for the Zone Picking Process
+
+ Zone picking of move lines.
+
+ Note:
+
+ * Several operation types could be linked to a single menu item
+ * If several operator work in a same zone, they’ll see the same move lines but
+ will only posts theirs when unloading their goods, which means that when they
+ scan lines, the backend has to store the user id on the move lines
+
+ Workflow:
+
+ 1. The operator scans the zone location with goods to pick (zone location
+ meaning a parent location, not a leaf)
+ 2. If the zone contains lines from different picking types, the operator
+ chooses the type to work with
+ 3. The client application shows the list of move lines, with an option
+ to choose the sorting of the lines
+ 4. The operator selects a line to pick, by scanning one of:
+
+ * location, if only a single move line there; if a location is scanned
+ and it contains several move lines, the view is updated to show only
+ them
+ * package
+ * product
+ * lot
+
+ 5. The operator scans the destination for the line they scanned, this is where
+ the path splits:
+
+ * they scan a location, in which case the move line's destination is
+ updated with it and the move is done
+ * they scan a package, which becomes the destination package of the move
+ line, the move line is not set to done, its ``qty_done`` is updated
+ and a field ``shopfloor_user_id`` is set to the user; consider the
+ move line is set in a buffer
+
+ 6. At any point, from the list of moves, the operator can reach the
+ "unload" screens to unload what they had put into the buffer (scanned a
+ destination package during step 5.). This is optional as they can directly
+ move whole pallets by scanning the destination in step 5.
+ 7. The unload screens (similar to those of the Cluster Picking workflow) are
+ used to move what has been put in the buffer:
+
+ * if the original destination of all the lines is unique, screen allows
+ to scan a single destination; they can use a "split" button to go to
+ the line by line screen
+ * if the lines have different destinations, they have to scan the destination
+ package, then scan the destination location, scan the next package and its
+ destination and so on.
+
+ The list of move lines (point 4.) has support functions:
+
+ * Change a lot or pack: if the expected lot is at the very bottom of the
+ location or a stock error forces a user to change lot or pack, user can
+ do it during the picking.
+ * Declare stock out: if a good is in fact not in stock or only partially.
+ Note the move lines may become unavailable or partially unavailable and
+ generate a back-order.
+
+ """
+
+ _inherit = "base.shopfloor.process"
+ _name = "shopfloor.zone.picking"
+ _usage = "zone_picking"
+ _description = __doc__
+
+ def _response_for_start(self, message=None):
+ return self._response(next_state="start", message=message)
+
+ def _response_for_select_picking_type(
+ self, zone_location, picking_types, message=None
+ ):
+ return self._response(
+ next_state="select_picking_type",
+ data=self._data_for_select_picking_type(zone_location, picking_types),
+ message=message,
+ )
+
+ def _response_for_select_line(
+ self, zone_location, picking_type, move_lines, message=None, popup=None
+ ):
+ return self._response(
+ next_state="select_line",
+ data=self._data_for_move_lines(zone_location, picking_type, move_lines),
+ message=message,
+ popup=popup,
+ )
+
+ def _response_for_set_line_destination(
+ self, zone_location, picking_type, move_line, message=None
+ ):
+ return self._response(
+ next_state="set_line_destination",
+ data=self._data_for_move_line(zone_location, picking_type, move_line),
+ message=message,
+ )
+
+ def _response_for_confirm_set_line_destination(
+ self, zone_location, picking_type, move_line, message=None
+ ):
+ return self._response(
+ next_state="confirm_set_line_destination",
+ data=self._data_for_move_line(zone_location, picking_type, move_line),
+ message=message,
+ )
+
+ def _response_for_zero_check(
+ self, zone_location, picking_type, location, message=None
+ ):
+ return self._response(
+ next_state="zero_check",
+ data=self._data_for_move_line(zone_location, picking_type, location),
+ message=message,
+ )
+
+ def _response_for_change_pack_lot(
+ self, zone_location, picking_type, move_line, message=None
+ ):
+ return self._response(
+ next_state="change_pack_lot",
+ data=self._data_for_move_line(zone_location, picking_type, move_line),
+ message=message,
+ )
+
+ def _response_for_unload_all(
+ self, zone_location, picking_type, move_lines, message=None
+ ):
+ return self._response(
+ next_state="unload_all",
+ data=self._data_for_move_lines(zone_location, picking_type, move_lines),
+ message=message,
+ )
+
+ def _response_for_confirm_unload_all(
+ self, zone_location, picking_type, move_lines, message=None
+ ):
+ return self._response(
+ next_state="confirm_unload_all",
+ data=self._data_for_move_lines(zone_location, picking_type, move_lines),
+ message=message,
+ )
+
+ def _response_for_unload_single(
+ self, zone_location, picking_type, move_line, message=None, popup=None
+ ):
+ return self._response(
+ next_state="unload_single",
+ data=self._data_for_move_line(zone_location, picking_type, move_line),
+ message=message,
+ popup=popup,
+ )
+
+ def _response_for_unload_set_destination(
+ self, zone_location, picking_type, move_line, message=None
+ ):
+ return self._response(
+ next_state="unload_set_destination",
+ data=self._data_for_move_line(zone_location, picking_type, move_line),
+ message=message,
+ )
+
+ def _response_for_confirm_unload_set_destination(
+ self, zone_location, picking_type, move_line
+ ):
+ return self._response(
+ next_state="confirm_unload_set_destination",
+ data=self._data_for_move_line(zone_location, picking_type, move_line),
+ )
+
+ def _data_for_select_picking_type(self, zone_location, picking_types):
+ return {
+ "zone_location": self.data.location(zone_location),
+ # available picking types to choose from
+ # TODO add lines count and priority lines count in the data
+ "picking_types": self.data.picking_types(picking_types),
+ }
+
+ def _data_for_move_line(self, zone_location, picking_type, move_line):
+ return {
+ "zone_location": self.data.location(zone_location),
+ "picking_type": self.data.picking_type(picking_type),
+ "move_line": self.data.move_line(move_line),
+ }
+
+ def _data_for_move_lines(self, zone_location, picking_type, move_lines):
+ return {
+ "zone_location": self.data.location(zone_location),
+ "picking_type": self.data.picking_type(picking_type),
+ # TODO sorting, ... (but maybe the lines are already sorted when passed)
+ "move_lines": self.data.move_lines(move_lines),
+ }
+
+ def scan_location(self, barcode):
+ """Scan the zone location where the picking should occur
+
+ The location must be a sub-location of one of the picking types'
+ default destination locations of the menu.
+
+ Transitions:
+ * start: invalid barcode
+ * select_picking_type: the location is valid, user has to choose a picking type
+ """
+ return self._response()
+
+ def list_move_lines(self, zone_location_id, picking_type_id, order="priority"):
+ """List all move lines to pick, sorted
+
+ Transitions:
+ * select_line: show the list of move lines
+ """
+ return self._response()
+
+ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priority"):
+ """Select a move line or narrow the list of move lines
+
+ When the barcode is a location and we can unambiguously know which move
+ line is picked (the quants in the location has one product/lot/package,
+ matching a single move line), then the move line is selected.
+ Otherwise, the list of move lines is refreshed with a filter on the
+ scanned location, showing the move lines that have this location as
+ source.
+
+ When the barcode is a package, a product or a lot, the first matching
+ line is selected.
+
+ A selected line goes to the next screen to select the destination
+ location or package.
+
+ Transitions:
+ * select_line: barcode not found or narrow the list on a location
+ * set_line_destination: a line has been selected for picking
+ """
+ return self._response()
+
+ def set_destination(
+ self,
+ zone_location_id,
+ picking_type_id,
+ move_line_id,
+ barcode,
+ quantity,
+ confirmation=False,
+ ):
+ """Set a destination location (and done) or a destination package (in buffer)
+
+ When a line is picked, it can either:
+
+ * be moved directly to a destination location, typically a pallet
+ * be moved to a destination package, that we'll call buffer in the docstrings
+
+ When the barcode is a valid location, actions on the move line:
+
+ * destination location set to the scanned one
+ * the quantity done is set to the passed quantity
+ * if the move has other move lines, it is split to have only this move line
+ * set to done (without backorder)
+
+ A valid location is a sub-location of the original destination, or a
+ sub-location of the picking type's default destination location if
+ ``confirmation`` is True.
+
+ When the barcode is a valid package, actions on the move line:
+
+ * destination package is set to the scanned one
+ * the quantity done is set to the passed quantity
+ * the field ``shopfloor_user_id`` is updated with the current user
+
+ Those fields will be used to identify which move lines are in the buffer.
+
+ A valid package is:
+
+ * an empty package
+ * not used as destination for another move line
+
+ Transitions:
+ * select_line: destination has been set, showing the next lines to pick
+ * zero_check: if the quantity of product moved is 0 in the source
+ location after the move (beware: at this point the product we put in
+ the buffer is still considered to be in the source location, so we
+ have to compute the source location's quantity - qty_done).
+ * set_line_destination: the scanned location is invalid, user has to
+ scan another one
+ * confirm_set_line_destination: the scanned location is not in the
+ expected one but is valid (in picking type's default destination)
+ """
+ # TODO on _action_done, use ``_sf_no_backorder`` in the
+ # context to disable backorders (see override in stock_picking.py).
+ return self._response()
+
+ def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero):
+ """Confirm or not if the source location of a move has zero qty
+
+ If the user confirms there is zero quantity, it means the stock was
+ correct and there is nothing to do. If the user says "no", a draft
+ empty inventory is created for the product (with lot if tracked).
+
+ Transitions:
+ * select_line: whether the user confirms or not the location is empty,
+ go back to the picking of lines
+ """
+ # TODO look in cluster_picking.py, same function exists
+ return self._response()
+
+ def stock_issue(self, zone_location_id, picking_type_id, move_line_id):
+ """Declare a stock issue for a line
+
+ After errors in the stock, the user cannot take all the products
+ because there is physically not enough goods. The move line is deleted
+ (unreserve), and an inventory is created to reduce the quantity in the
+ source location to prevent future errors until a correction. Beware:
+ the quantity already reserved and having a qty_done set on other lines
+ in the same location should remain reserved so the inventory's quantity
+ must be set to the total of qty_done of other lines.
+
+ The other lines not yet picked (no qty_done) in the same location for
+ the same product, lot, package are unreserved as well (moves lines
+ deleted, which unreserve their quantity on the move).
+
+ A second inventory is created in draft to have someone do an inventory
+ check.
+
+ At the end, it tries to reserve the goods again, and if the current
+ line could be reserved in the current zone location, it transitions
+ directly to the screen to set the destination.
+
+ Transitions:
+ * select_line: go back to the picking of lines for the next ones (nothing
+ could be reserved as replacement)
+ * set_line_destination: something could be reserved instead of the original
+ move line
+ """
+ # TODO look in cluster_picking.py, similar function exists
+ return self._response()
+
+ def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barcode):
+ """Change the source package or the lot of a move line
+
+ If the expected lot or package is at the very bottom of the location or
+ a stock error forces a user to change lot or package, user can change the
+ package or lot of the current move line.
+
+ If the pack or lot was not supposed to be in the source location,
+ a draft inventory is created to have this checked.
+
+ Transitions:
+ * change_pack_lot: the barcode scanned is invalid or change could not be done
+ * set_line_destination: the package / lot has been changed, can be
+ moved to destination now
+ * select_line: if the move line does not exist anymore
+ """
+ # TODO look in cluster_picking.py, similar function exists
+ return self._response()
+
+ def prepare_unload(self, zone_location_id, picking_type_id):
+ """Initiate the unloading of the buffer
+
+ The buffer is composed of move lines:
+
+ * in the current zone location and picking type
+ * not done or canceled
+ * with a qty_done > 0
+ * have a destination package
+ * with ``shopfloor_user_id`` equal to the current user
+
+ The lines are grouped by their destination package. The destination
+ package is what is shown on the screen (with their content, which is
+ the move lines with the package as destination), and this is what is
+ passed along in the ``package_id`` parameters in the unload methods.
+
+ It goes to different screens depending if there is only one move line,
+ or if all the move lines have the same destination or not.
+
+ Transitions:
+ * unload_single: move lines have different destinations, return data
+ for the next destination package
+ * unload_set_destination: there is only one move line in the buffer
+ * unload_all: the move lines in the buffer all have the same
+ destination location
+ * select_line: no remaining move lines in buffer
+ """
+ return self._response()
+
+ def set_destination_all(
+ self, zone_location_id, picking_type_id, barcode, confirmation=False
+ ):
+ """Set the destination for all the lines in the buffer
+
+ Look in ``prepare_unload`` for the definition of the buffer.
+
+ This method must be used only if all the buffer's move lines which have
+ a destination package, qty done > 0, and have the same destination
+ location.
+
+ A scanned location outside of the source location of the operation type is
+ invalid.
+
+ The move lines are then set to done, without backorders.
+
+ Transitions:
+ * unload_all: the scanned destination is invalid, user has to
+ scan another one
+ * confirm_unload_all: the scanned location is not in the
+ expected one but is valid (in picking type's default destination)
+ * select_line: no remaining move lines in buffer
+ """
+ return self._response()
+
+ def unload_split(self, zone_location_id, picking_type_id):
+ """Indicates that now the buffer must be treated line per line
+
+ Called from a button, users decides to handle destinations one by one.
+ Even if the move lines to unload all have the same destination.
+
+ Look in ``prepare_unload`` for the definition of the buffer.
+
+ Transitions:
+ * unload_single: more than one remaining line in the buffer
+ * unload_set_destination: there is only one remaining line in the buffer
+ * select_line: no remaining move lines in buffer
+ """
+ return self._response()
+
+ def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcode):
+ """Scan the destination package to check user moves the good one
+
+ The "unload_single" screen proposes a package (which has been
+ previously been set as destination package of lines of the buffer).
+ The user has to scan the package to validate they took the good one.
+
+ Transitions:
+ * unload_single: the scanned barcode does not match the package
+ * unload_set_destination: the scanned barcode matches the package
+ * confirm_unload_set_destination: the scanned location is not in the
+ expected one but is valid (in picking type's default destination)
+ * select_line: no remaining move lines in buffer
+ """
+ return self._response()
+
+ def unload_set_destination(
+ self, zone_location_id, picking_type_id, package_id, barcode, confirmation=False
+ ):
+ """Scan the final destination for move lines in the buffer with the
+ destination package
+
+ All the move lines in the buffer with the package_id as destination
+ package are updated with the scanned location.
+
+ The move lines are then set to done, without backorders.
+
+ Look in ``prepare_unload`` for the definition of the buffer.
+
+ Transitions:
+ * unload_single: buffer still contains move lines, unload the next package
+ * unload_set_destination: the scanned location is invalid, user has to
+ scan another one
+ * confirm_unload_set_destination: the scanned location is not in the
+ expected one but is valid (in picking type's default destination)
+ * select_line: no remaining move lines in buffer
+ """
+ # TODO on _action_done, use ``_sf_no_backorder`` in the
+ # context to disable backorders (see override in stock_picking.py).
+ # TODO return a popup with completion info alongside the response,
+ # see in cluster_picking.py how it's done
+ return self._response()
+
+
+class ShopfloorZonePickingValidator(Component):
+ """Validators for the Zone Picking endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.zone_picking.validator"
+ _usage = "zone_picking.validator"
+
+ def scan_location(self):
+ return {"barcode": {"required": True, "type": "string"}}
+
+ def list_move_lines(self):
+ return {
+ "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": False, "nullable": True, "type": "string"},
+ "order": {
+ "required": False,
+ "type": "string",
+ "allowed": ["priority", "location"],
+ },
+ }
+
+ def scan_source(self):
+ return {
+ "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": False, "nullable": True, "type": "string"},
+ "order": {
+ "required": False,
+ "type": "string",
+ "allowed": ["priority", "location"],
+ },
+ }
+
+ def set_destination(self):
+ return {
+ "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": False, "nullable": True, "type": "string"},
+ "order": {
+ "required": False,
+ "type": "string",
+ "allowed": ["priority", "location"],
+ },
+ "quantity": {"coerce": to_float, "required": True, "type": "float"},
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
+ }
+
+ def is_zero(self):
+ return {
+ "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "zero": {"coerce": to_bool, "required": True, "type": "boolean"},
+ }
+
+ def stock_issue(self):
+ return {
+ "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def change_pack_lot(self):
+ return {
+ "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": False, "nullable": True, "type": "string"},
+ }
+
+ def prepare_unload(self):
+ return {
+ "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def set_destination_all(self):
+ return {
+ "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": False, "nullable": True, "type": "string"},
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
+ }
+
+ def unload_split(self):
+ return {
+ "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def unload_scan_pack(self):
+ return {
+ "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
+ def unload_set_destination(self):
+ return {
+ "zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": False, "nullable": True, "type": "string"},
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
+ }
+
+
+class ShopfloorZonePickingValidatorResponse(Component):
+ """Validators for the Zone Picking endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.zone_picking.validator.response"
+ _usage = "zone_picking.validator.response"
+
+ def _states(self):
+ """List of possible next states
+
+ With the schema of the data send to the client to transition
+ to the next state.
+ """
+ return {
+ "start": {},
+ "select_picking_type": self._schema_for_select_picking_type,
+ "select_line": self._schema_for_move_lines,
+ "set_line_destination": self._schema_for_move_line,
+ "confirm_set_line_destination": self._schema_for_move_line,
+ "zero_check": self._schema_for_move_line,
+ "change_pack_lot": self._schema_for_move_line,
+ "unload_all": self._schema_for_move_lines,
+ "confirm_unload_all": self._schema_for_move_lines,
+ "unload_single": self._schema_for_move_line,
+ "unload_set_destination": self._schema_for_move_line,
+ "confirm_unload_set_destination": self._schema_for_move_line,
+ }
+
+ def scan_location(self):
+ return self._response_schema(next_states={"start", "select_picking_type"})
+
+ def list_move_lines(self):
+ return self._response_schema(next_states={"select_line"})
+
+ def scan_source(self):
+ return self._response_schema(
+ next_states={"select_line", "set_line_destination"}
+ )
+
+ def set_destination(self):
+ return self._response_schema(
+ next_states={
+ "select_line",
+ "set_line_destination",
+ "confirm_set_line_destination",
+ "zero_check",
+ }
+ )
+
+ def is_zero(self):
+ return self._response_schema(next_states={"select_line"})
+
+ def stock_issue(self):
+ return self._response_schema(
+ next_states={"select_line", "set_line_destination"}
+ )
+
+ def change_pack_lot(self):
+ return self._response_schema(
+ next_states={"change_pack_lot", "set_line_destination", "select_line"}
+ )
+
+ def prepare_unload(self):
+ return self._response_schema(
+ next_states={
+ "unload_all",
+ "unload_single",
+ "unload_set_destination",
+ "select_line",
+ }
+ )
+
+ def set_destination_all(self):
+ return self._response_schema(
+ next_states={"unload_all", "confirm_unload_all", "select_line"}
+ )
+
+ def unload_split(self):
+ return self._response_schema(
+ next_states={"unload_single", "unload_set_destination", "select_line"}
+ )
+
+ def unload_scan_pack(self):
+ return self._response_schema(
+ next_states={"unload_single", "unload_set_destination", "select_line"}
+ )
+
+ def unload_set_destination(self):
+ return self._response_schema(
+ next_states={
+ "unload_single",
+ "unload_set_destination",
+ "confirm_unload_set_destination",
+ "select_line",
+ }
+ )
+
+ @property
+ def _schema_for_select_picking_type(self):
+ schema = {
+ "zone_location": self.schemas._schema_dict_of(self.schemas.location()),
+ "picking_types": self.schemas._schema_list_of(self.schemas.picking_type()),
+ }
+ return schema
+
+ @property
+ def _schema_for_move_line(self):
+ schema = {
+ "zone_location": self.schemas._schema_dict_of(self.schemas.location()),
+ "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()),
+ "move_line": self.schemas._schema_dict_of(self.schemas.move_line()),
+ }
+ return schema
+
+ @property
+ def _schema_for_move_lines(self):
+ schema = {
+ "zone_location": self.schemas._schema_dict_of(self.schemas.location()),
+ "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()),
+ "move_lines": self.schemas._schema_list_of(self.schemas.move_line()),
+ }
+ return schema
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 61dfbc481b..f55c20ab3c 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -5,6 +5,7 @@
from . import test_actions_data
from . import test_actions_data_detail
from . import test_single_pack_transfer
+from . import test_zone_picking_base
from . import test_cluster_picking_base
from . import test_cluster_picking_batch
from . import test_cluster_picking_select
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
new file mode 100644
index 0000000000..9a5b9f895a
--- /dev/null
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -0,0 +1,20 @@
+from .common import CommonCase
+
+
+class ZonePickingCommonCase(CommonCase):
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_zone_picking")
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.picking_type = cls.menu.picking_type_ids
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
+ self.service = work.component(usage="zone_picking")
From 819875f9b2a49a6173be23787b27f4af48073cf6 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 7 Jul 2020 09:34:24 +0200
Subject: [PATCH 293/940] Add note for optional zero check
---
shopfloor/services/zone_picking.py | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 99172cfdd7..ea5636c7e0 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -284,10 +284,11 @@ def set_destination(
Transitions:
* select_line: destination has been set, showing the next lines to pick
- * zero_check: if the quantity of product moved is 0 in the source
- location after the move (beware: at this point the product we put in
- the buffer is still considered to be in the source location, so we
- have to compute the source location's quantity - qty_done).
+ * zero_check: if the option is active and if the quantity of product
+ moved is 0 in the source location after the move (beware: at this
+ point the product we put in the buffer is still considered to be in
+ the source location, so we have to compute the source location's
+ quantity - qty_done).
* set_line_destination: the scanned location is invalid, user has to
scan another one
* confirm_set_line_destination: the scanned location is not in the
@@ -295,6 +296,9 @@ def set_destination(
"""
# TODO on _action_done, use ``_sf_no_backorder`` in the
# context to disable backorders (see override in stock_picking.py).
+ # NOTE for the implementation: zero_check is active only if the option
+ # is active on the picking_type (maybe shopfloor.menu), check in
+ # cluster picking how it's done
return self._response()
def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero):
From db31707956a496500919028f19c3ea9db29d2c86 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 7 Jul 2020 11:15:10 +0200
Subject: [PATCH 294/940] Add comment
---
shopfloor/services/zone_picking.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index ea5636c7e0..12a7629b0a 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -197,6 +197,7 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines):
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
# TODO sorting, ... (but maybe the lines are already sorted when passed)
+ # also, check https://github.com/camptocamp/wms/pull/29
"move_lines": self.data.move_lines(move_lines),
}
From 1e26a7eb5a8ca0ea9eb4dcd63f9a62b7a1a9cd40 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Fri, 17 Jul 2020 09:08:10 +0200
Subject: [PATCH 295/940] zone picking: implement /scan_location endpoint
---
shopfloor/actions/data.py | 42 ++++++++++++-
shopfloor/services/schema.py | 9 ++-
shopfloor/services/zone_picking.py | 34 +++++++++-
shopfloor/tests/__init__.py | 3 +-
shopfloor/tests/test_zone_picking_base.py | 73 ++++++++++++++++++++++
shopfloor/tests/test_zone_picking_start.py | 47 ++++++++++++++
6 files changed, 202 insertions(+), 6 deletions(-)
create mode 100644 shopfloor/tests/test_zone_picking_start.py
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 2ff2cbdd2b..10f04f8269 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -225,4 +225,44 @@ def picking_types(self, record, **kw):
@property
def _picking_type_parser(self):
- return ["id", "name"]
+ return [
+ "id",
+ "name",
+ ("lines_count", self._picking_type_lines_count),
+ ("picking_count", self._picking_type_picking_count),
+ ("priority_lines_count", self._picking_type_priority_lines_count),
+ ("priority_picking_count", self._picking_type_priority_picking_count),
+ ]
+
+ def _picking_type_lines_count(self, rec, field):
+ return self.env["stock.move.line"].search_count(
+ [
+ ("picking_id.picking_type_id", "=", rec.id),
+ ("qty_done", "=", 0),
+ ("state", "in", ("assigned", "partially_available")),
+ ]
+ )
+
+ def _picking_type_priority_lines_count(self, rec, field):
+ return self.env["stock.move.line"].search_count(
+ [
+ ("picking_id.picking_type_id", "=", rec.id),
+ ("qty_done", "=", 0),
+ ("state", "in", ("assigned", "partially_available")),
+ ("picking_id.priority", "in", ["2", "3"]),
+ ]
+ )
+
+ def _picking_type_picking_count(self, rec, field):
+ return self.env["stock.picking"].search_count(
+ [("picking_type_id", "=", rec.id), ("state", "not in", ("cancel", "done"))]
+ )
+
+ def _picking_type_priority_picking_count(self, rec, field):
+ return self.env["stock.picking"].search_count(
+ [
+ ("picking_type_id", "=", rec.id),
+ ("state", "not in", ("cancel", "done")),
+ ("priority", "in", ["2", "3"]),
+ ]
+ )
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index f686a61695..59c085c033 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -166,4 +166,11 @@ def package_level(self):
}
def picking_type(self):
- return self._simple_record()
+ return {
+ "id": {"required": True, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": True},
+ "lines_count": {"type": "float", "required": True},
+ "picking_count": {"type": "float", "required": True},
+ "priority_lines_count": {"type": "float", "required": True},
+ "priority_picking_count": {"type": "float", "required": True},
+ }
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 12a7629b0a..d433eeef38 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -181,7 +181,6 @@ def _data_for_select_picking_type(self, zone_location, picking_types):
return {
"zone_location": self.data.location(zone_location),
# available picking types to choose from
- # TODO add lines count and priority lines count in the data
"picking_types": self.data.picking_types(picking_types),
}
@@ -201,17 +200,46 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines):
"move_lines": self.data.move_lines(move_lines),
}
+ def _find_location_move_lines_domain(self, location):
+ return [
+ ("location_id", "child_of", location.id),
+ ("qty_done", "=", 0),
+ ("state", "in", ("assigned", "partially_available")),
+ ]
+
+ def _find_location_move_lines(self, location):
+ """Find lines that potentially are to move in the location"""
+ return self.env["stock.move.line"].search(
+ self._find_location_move_lines_domain(location)
+ )
+
def scan_location(self, barcode):
"""Scan the zone location where the picking should occur
The location must be a sub-location of one of the picking types'
- default destination locations of the menu.
+ default source locations of the menu.
Transitions:
* start: invalid barcode
* select_picking_type: the location is valid, user has to choose a picking type
"""
- return self._response()
+ search = self.actions_for("search")
+ zone_location = search.location_from_scan(barcode)
+ if not zone_location:
+ return self._response_for_start(message=self.msg_store.no_location_found())
+ if not zone_location.is_sublocation_of(
+ self.work.menu.picking_type_ids.mapped("default_location_src_id")
+ ):
+ return self._response_for_start(
+ message=self.msg_store.location_not_allowed()
+ )
+ move_lines = self._find_location_move_lines(zone_location)
+ if not move_lines:
+ return self._response_for_start(
+ message=self.msg_store.no_lines_to_process()
+ )
+ picking_types = move_lines.picking_id.picking_type_id
+ return self._response_for_select_picking_type(zone_location, picking_types)
def list_move_lines(self, zone_location_id, picking_type_id, order="priority"):
"""List all move lines to pick, sorted
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index f55c20ab3c..a4791dcc17 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -5,7 +5,6 @@
from . import test_actions_data
from . import test_actions_data_detail
from . import test_single_pack_transfer
-from . import test_zone_picking_base
from . import test_cluster_picking_base
from . import test_cluster_picking_batch
from . import test_cluster_picking_select
@@ -39,3 +38,5 @@
from . import test_location_content_transfer_set_destination_all
from . import test_location_content_transfer_single
from . import test_location_content_transfer_set_destination_package_or_line
+from . import test_zone_picking_base
+from . import test_zone_picking_start
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 9a5b9f895a..1a63f61aad 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -13,8 +13,81 @@ def setUpClassVars(cls, *args, **kwargs):
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
+ # We want to limit the tests to a dedicated location in Stock/ to not
+ # be bothered with pickings brought by demo data
+ cls.zone_location = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone location",
+ "location_id": cls.stock_location.id,
+ "barcode": "ZONE_LOCATION",
+ }
+ )
+ )
+ cls.zone_sublocation = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone sub-location",
+ "location_id": cls.zone_location.id,
+ "barcode": "ZONE_SUBLOCATION",
+ }
+ )
+ )
+ products = cls.product_a + cls.product_b + cls.product_c + cls.product_d
+ for product in products:
+ cls.env["stock.putaway.rule"].sudo().create(
+ {
+ "product_id": product.id,
+ "location_in_id": cls.stock_location.id,
+ "location_out_id": cls.shelf1.id,
+ }
+ )
+
+ cls.picking1 = picking1 = cls._create_picking(
+ lines=[(cls.product_a, 10), (cls.product_b, 10)]
+ )
+ cls.picking2 = picking2 = cls._create_picking(
+ lines=[(cls.product_c, 10), (cls.product_d, 10)]
+ )
+ cls.pickings = picking1 | picking2
+ cls._fill_stock_for_moves(
+ picking1.move_lines, in_package=True, location=cls.zone_sublocation
+ )
+ cls._fill_stock_for_moves(picking2.move_lines, location=cls.zone_sublocation)
+ cls.pickings.action_assign()
def setUp(self):
super().setUp()
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="zone_picking")
+
+ def assert_response_start(self, response, message=None):
+ self.assert_response(response, next_state="start", message=message)
+
+ def _assert_response_select_picking_type(
+ self, state, response, zone_location, picking_types, message=None
+ ):
+ self.assert_response(
+ response,
+ next_state=state,
+ data={
+ "zone_location": self.data.location(zone_location),
+ "picking_types": self.data.picking_types(picking_types),
+ },
+ message=message,
+ )
+
+ def assert_response_select_picking_type(
+ self, response, zone_location, picking_types, message=None
+ ):
+ self._assert_response_select_picking_type(
+ "select_picking_type",
+ response,
+ zone_location,
+ picking_types,
+ message=message,
+ )
diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py
new file mode 100644
index 0000000000..b339bbd893
--- /dev/null
+++ b/shopfloor/tests/test_zone_picking_start.py
@@ -0,0 +1,47 @@
+from .test_zone_picking_base import ZonePickingCommonCase
+
+
+class ZonePickingStartCase(ZonePickingCommonCase):
+ """Tests for endpoint used from start
+
+ * /scan_location
+
+ """
+
+ def test_scan_location_wrong_barcode(self):
+ """Scanned location invalid, no location found."""
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": "UNKNOWN LOCATION"},
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.no_location_found(),
+ )
+
+ def test_scan_location_not_allowed(self):
+ """Scanned location not allowed because it's not a child of picking
+ types' source location.
+ """
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.customer_location.barcode},
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.location_not_allowed(),
+ )
+
+ def test_scan_location_no_move_lines(self):
+ """Scanned location valid, but no move lines found in it."""
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.shelf2.barcode},
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.no_lines_to_process(),
+ )
+
+ def test_scan_location_ok(self):
+ """Scanned location valid, list of picking types of related move lines."""
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.zone_location.barcode},
+ )
+ self.assert_response_select_picking_type(
+ response, zone_location=self.zone_location, picking_types=self.picking_type,
+ )
From 554e7bd2f71b99fada588c524669e94890fa4dbd Mon Sep 17 00:00:00 2001
From: sebalix
Date: Fri, 17 Jul 2020 15:58:05 +0200
Subject: [PATCH 296/940] zone picking: implement /list_move_lines endpoint
---
shopfloor/services/zone_picking.py | 20 ++++++--
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_zone_picking_base.py | 41 +++++++++++++++
.../test_zone_picking_select_picking_type.py | 51 +++++++++++++++++++
4 files changed, 108 insertions(+), 5 deletions(-)
create mode 100644 shopfloor/tests/test_zone_picking_select_picking_type.py
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index d433eeef38..b3df881ef7 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -200,17 +200,20 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines):
"move_lines": self.data.move_lines(move_lines),
}
- def _find_location_move_lines_domain(self, location):
- return [
+ def _find_location_move_lines_domain(self, location, picking_type=None):
+ domain = [
("location_id", "child_of", location.id),
("qty_done", "=", 0),
("state", "in", ("assigned", "partially_available")),
]
+ if picking_type:
+ domain += [("picking_id.picking_type_id", "=", picking_type.id)]
+ return domain
- def _find_location_move_lines(self, location):
+ def _find_location_move_lines(self, location, picking_type=None):
"""Find lines that potentially are to move in the location"""
return self.env["stock.move.line"].search(
- self._find_location_move_lines_domain(location)
+ self._find_location_move_lines_domain(location, picking_type)
)
def scan_location(self, barcode):
@@ -247,7 +250,14 @@ def list_move_lines(self, zone_location_id, picking_type_id, order="priority"):
Transitions:
* select_line: show the list of move lines
"""
- return self._response()
+ zone_location = self.env["stock.location"].browse(zone_location_id)
+ if not zone_location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ picking_type = self.env["stock.picking.type"].browse(picking_type_id)
+ if not picking_type.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ move_lines = self._find_location_move_lines(zone_location, picking_type)
+ return self._response_for_select_line(zone_location, picking_type, move_lines)
def scan_source(self, zone_location_id, picking_type_id, barcode, order="priority"):
"""Select a move line or narrow the list of move lines
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index a4791dcc17..c100ca244a 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -40,3 +40,4 @@
from . import test_location_content_transfer_set_destination_package_or_line
from . import test_zone_picking_base
from . import test_zone_picking_start
+from . import test_zone_picking_select_picking_type
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 1a63f61aad..5b708639a1 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -91,3 +91,44 @@ def assert_response_select_picking_type(
picking_types,
message=message,
)
+
+ def _assert_response_select_line(
+ self,
+ state,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=None,
+ popup=None,
+ ):
+ self.assert_response(
+ response,
+ next_state=state,
+ data={
+ "zone_location": self.data.location(zone_location),
+ "picking_type": self.data.picking_type(picking_type),
+ "move_lines": self.data.move_lines(move_lines),
+ },
+ message=message,
+ popup=popup,
+ )
+
+ def assert_response_select_line(
+ self,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=None,
+ popup=None,
+ ):
+ self._assert_response_select_line(
+ "select_line",
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=message,
+ popup=popup,
+ )
diff --git a/shopfloor/tests/test_zone_picking_select_picking_type.py b/shopfloor/tests/test_zone_picking_select_picking_type.py
new file mode 100644
index 0000000000..a07ee6be9b
--- /dev/null
+++ b/shopfloor/tests/test_zone_picking_select_picking_type.py
@@ -0,0 +1,51 @@
+from .test_zone_picking_base import ZonePickingCommonCase
+
+
+class ZonePickingSelectPickingTypeCase(ZonePickingCommonCase):
+ """Tests for endpoint used from select_picking_type
+
+ * /list_move_lines
+
+ """
+
+ def test_list_move_lines_wrong_parameters(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "list_move_lines",
+ params={
+ "zone_location_id": 1234567890,
+ "picking_type_id": picking_type.id,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "list_move_lines",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": 1234567890,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_list_move_lines_ok(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "list_move_lines",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ },
+ )
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response,
+ zone_location=self.zone_location,
+ picking_type=self.picking_type,
+ move_lines=move_lines,
+ )
From d9c702076ae31f5095047e26f4cedf1908f8dd71 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Sat, 18 Jul 2020 13:24:56 +0200
Subject: [PATCH 297/940] zone picking: implement /scan_source endpoint
---
shopfloor/actions/message.py | 20 +-
shopfloor/services/zone_picking.py | 156 +++++++++-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_zone_picking_base.py | 71 ++++-
.../tests/test_zone_picking_select_line.py | 291 ++++++++++++++++++
5 files changed, 521 insertions(+), 18 deletions(-)
create mode 100644 shopfloor/tests/test_zone_picking_select_line.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 9333acb952..8c422b7e33 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -152,7 +152,13 @@ def confirm_canceled_scan_next_pack(self):
def no_pack_in_location(self, location):
return {
"message_type": "error",
- "body": _("Location %s doesn't contain any package." % location.name),
+ "body": _("Location %s doesn't contain any package.") % location.name,
+ }
+
+ def several_lines_in_location(self, location):
+ return {
+ "message_type": "warning",
+ "body": _("Several lines found in %s, please scan one.") % location.name,
}
def several_packs_in_location(self, location):
@@ -252,6 +258,12 @@ def product_mixed_package_scan_package(self):
),
}
+ def product_not_found(self):
+ return {
+ "message_type": "error",
+ "body": _("This product does not exist anymore."),
+ }
+
def product_not_found_in_pickings(self):
return {
"message_type": "warning",
@@ -275,6 +287,12 @@ def lot_multiple_packages_scan_package(self):
"body": _("This lot is part of multiple packages, please scan a package."),
}
+ def lot_not_found(self):
+ return {
+ "message_type": "error",
+ "body": _("This lot does not exist anymore."),
+ }
+
def lot_not_found_in_pickings(self):
return {
"message_type": "warning",
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index b3df881ef7..2d015fc31a 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1,3 +1,5 @@
+from odoo.fields import first
+
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
@@ -195,12 +197,12 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines):
return {
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
- # TODO sorting, ... (but maybe the lines are already sorted when passed)
- # also, check https://github.com/camptocamp/wms/pull/29
"move_lines": self.data.move_lines(move_lines),
}
- def _find_location_move_lines_domain(self, location, picking_type=None):
+ def _find_location_move_lines_domain(
+ self, location, picking_type=None, package=None, product=None, lot=None
+ ):
domain = [
("location_id", "child_of", location.id),
("qty_done", "=", 0),
@@ -208,13 +210,46 @@ def _find_location_move_lines_domain(self, location, picking_type=None):
]
if picking_type:
domain += [("picking_id.picking_type_id", "=", picking_type.id)]
+ if package:
+ domain += [("package_id", "=", package.id)]
+ if product:
+ domain += [("product_id", "=", product.id)]
+ if lot:
+ domain += [("lot_id", "=", lot.id)]
return domain
- def _find_location_move_lines(self, location, picking_type=None):
+ def _find_location_move_lines(
+ self,
+ location,
+ picking_type=None,
+ package=None,
+ product=None,
+ lot=None,
+ order="priority",
+ ):
"""Find lines that potentially are to move in the location"""
- return self.env["stock.move.line"].search(
- self._find_location_move_lines_domain(location, picking_type)
+ move_lines = self.env["stock.move.line"].search(
+ self._find_location_move_lines_domain(
+ location, picking_type, package, product, lot
+ )
)
+ sort_keys_func, reverse = self._sort_key_move_lines(order)
+ move_lines = move_lines.sorted(sort_keys_func, reverse=reverse)
+ return move_lines
+
+ @staticmethod
+ def _sort_key_move_lines(order):
+ """Return a `(sort_keys_func, reverse)` tuple for move lines."""
+ if order == "priority":
+ return lambda line: line.move_id.priority, True
+ elif order == "location":
+ return (
+ lambda line: (
+ line.location_id.shopfloor_picking_sequence,
+ line.location_id.name,
+ ),
+ False,
+ )
def scan_location(self, barcode):
"""Scan the zone location where the picking should occur
@@ -256,9 +291,53 @@ def list_move_lines(self, zone_location_id, picking_type_id, order="priority"):
picking_type = self.env["stock.picking.type"].browse(picking_type_id)
if not picking_type.exists():
return self._response_for_start(message=self.msg_store.record_not_found())
- move_lines = self._find_location_move_lines(zone_location, picking_type)
+ move_lines = self._find_location_move_lines(
+ zone_location, picking_type, order=order
+ )
return self._response_for_select_line(zone_location, picking_type, move_lines)
+ def _scan_source_location(self, zone_location, picking_type, location):
+ """Return the move line related to the scanned `location`.
+
+ The method tries to identify unambiguously a move line in the location
+ if possible, otherwise return `False`.
+ """
+ quants = self.env["stock.quant"].search([("location_id", "=", location.id)])
+ product = quants.product_id
+ lot = quants.lot_id
+ package = quants.package_id
+ if len(product) > 1 or len(lot) > 1 or len(package) > 1:
+ return False
+ domain = [("location_id", "=", location.id)]
+ if product:
+ domain.append(("product_id", "=", product.id))
+ if lot:
+ domain.append(("lot_id", "=", lot.id))
+ if package:
+ domain.append(("package_id", "=", package.id))
+ move_line = self.env["stock.move.line"].search(domain)
+ if len(move_line) == 1:
+ return move_line
+ raise False
+
+ def _scan_source_package(self, zone_location, picking_type, package, order):
+ move_lines = self._find_location_move_lines(
+ zone_location, picking_type, package=package, order=order
+ )
+ return first(move_lines)
+
+ def _scan_source_product(self, zone_location, picking_type, product, order):
+ move_lines = self._find_location_move_lines(
+ zone_location, picking_type, product=product, order=order
+ )
+ return first(move_lines)
+
+ def _scan_source_lot(self, zone_location, picking_type, lot, order):
+ move_lines = self._find_location_move_lines(
+ zone_location, picking_type, lot=lot, order=order
+ )
+ return first(move_lines)
+
def scan_source(self, zone_location_id, picking_type_id, barcode, order="priority"):
"""Select a move line or narrow the list of move lines
@@ -279,7 +358,68 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit
* select_line: barcode not found or narrow the list on a location
* set_line_destination: a line has been selected for picking
"""
- return self._response()
+ zone_location = self.env["stock.location"].browse(zone_location_id)
+ if not zone_location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ picking_type = self.env["stock.picking.type"].browse(picking_type_id)
+ if not picking_type.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ # select corresponding move line from barcode (location, package, product, lot)
+ search = self.actions_for("search")
+ move_line = self.env["stock.move.line"]
+ location = search.location_from_scan(barcode)
+ if location:
+ if not location.is_sublocation_of(zone_location):
+ return self._response_for_start(
+ message=self.msg_store.location_not_allowed()
+ )
+ move_line = self._scan_source_location(
+ zone_location, picking_type, location
+ )
+ # if no move line, narrow the list of move lines on the scanned location
+ if not move_line:
+ response = self.list_move_lines(location.id, picking_type.id)
+ return self._response(
+ base_response=response,
+ message=self.msg_store.several_lines_in_location(location),
+ )
+ package = search.package_from_scan(barcode)
+ if package:
+ move_line = self._scan_source_package(
+ zone_location, picking_type, package, order
+ )
+ if not move_line:
+ response = self.list_move_lines(zone_location.id, picking_type.id)
+ return self._response(
+ base_response=response, message=self.msg_store.package_not_found(),
+ )
+ product = search.product_from_scan(barcode)
+ if product:
+ move_line = self._scan_source_product(
+ zone_location, picking_type, product, order
+ )
+ if not move_line:
+ response = self.list_move_lines(zone_location.id, picking_type.id)
+ return self._response(
+ base_response=response, message=self.msg_store.product_not_found(),
+ )
+ lot = search.lot_from_scan(barcode)
+ if lot:
+ move_line = self._scan_source_lot(zone_location, picking_type, lot, order)
+ if not move_line:
+ response = self.list_move_lines(zone_location.id, picking_type.id)
+ return self._response(
+ base_response=response, message=self.msg_store.lot_not_found(),
+ )
+ # barcode not found, get back on 'select_line' screen
+ if not move_line:
+ response = self.list_move_lines(zone_location.id, picking_type.id)
+ return self._response(
+ base_response=response, message=self.msg_store.barcode_not_found(),
+ )
+ return self._response_for_set_line_destination(
+ zone_location, picking_type, move_line
+ )
def set_destination(
self,
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index c100ca244a..90a93e7bbe 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -41,3 +41,4 @@
from . import test_zone_picking_base
from . import test_zone_picking_start
from . import test_zone_picking_select_picking_type
+from . import test_zone_picking_select_line
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 5b708639a1..ed857bb349 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -26,14 +26,25 @@ def setUpClassBaseData(cls, *args, **kwargs):
}
)
)
- cls.zone_sublocation = (
+ cls.zone_sublocation1 = (
cls.env["stock.location"]
.sudo()
.create(
{
- "name": "Zone sub-location",
+ "name": "Zone sub-location 1",
"location_id": cls.zone_location.id,
- "barcode": "ZONE_SUBLOCATION",
+ "barcode": "ZONE_SUBLOCATION_1",
+ }
+ )
+ )
+ cls.zone_sublocation2 = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone sub-location 2",
+ "location_id": cls.zone_location.id,
+ "barcode": "ZONE_SUBLOCATION_2",
}
)
)
@@ -47,18 +58,34 @@ def setUpClassBaseData(cls, *args, **kwargs):
}
)
- cls.picking1 = picking1 = cls._create_picking(
- lines=[(cls.product_a, 10), (cls.product_b, 10)]
- )
+ cls.picking1 = picking1 = cls._create_picking(lines=[(cls.product_a, 10)])
cls.picking2 = picking2 = cls._create_picking(
- lines=[(cls.product_c, 10), (cls.product_d, 10)]
+ lines=[(cls.product_b, 10), (cls.product_c, 10)]
)
cls.pickings = picking1 | picking2
cls._fill_stock_for_moves(
- picking1.move_lines, in_package=True, location=cls.zone_sublocation
+ picking1.move_lines, in_package=True, location=cls.zone_sublocation1
+ )
+ cls._fill_stock_for_moves(
+ picking2.move_lines, in_lot=True, location=cls.zone_sublocation2
)
- cls._fill_stock_for_moves(picking2.move_lines, location=cls.zone_sublocation)
cls.pickings.action_assign()
+ # Some records not related at all to the processed move lines
+ cls.free_package = cls.env["stock.quant.package"].create(
+ {"name": "FREE_PACKAGE"}
+ )
+ cls.free_lot = cls.env["stock.production.lot"].create(
+ {
+ "name": "FREE_LOT",
+ "product_id": cls.product_a.id,
+ "company_id": cls.env.company.id,
+ }
+ )
+ cls.free_product = (
+ cls.env["product.product"]
+ .sudo()
+ .create({"name": "FREE_PRODUCT", "barcode": "FREE_PRODUCT"})
+ )
def setUp(self):
super().setUp()
@@ -132,3 +159,29 @@ def assert_response_select_line(
message=message,
popup=popup,
)
+
+ def _assert_response_set_line_destination(
+ self, state, response, zone_location, picking_type, move_line, message=None,
+ ):
+ self.assert_response(
+ response,
+ next_state=state,
+ data={
+ "zone_location": self.data.location(zone_location),
+ "picking_type": self.data.picking_type(picking_type),
+ "move_line": self.data.move_line(move_line),
+ },
+ message=message,
+ )
+
+ def assert_response_set_line_destination(
+ self, response, zone_location, picking_type, move_line, message=None,
+ ):
+ self._assert_response_set_line_destination(
+ "set_line_destination",
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=message,
+ )
diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py
new file mode 100644
index 0000000000..e9142500cc
--- /dev/null
+++ b/shopfloor/tests/test_zone_picking_select_line.py
@@ -0,0 +1,291 @@
+from .test_zone_picking_base import ZonePickingCommonCase
+
+
+class ZonePickingSelectLineCase(ZonePickingCommonCase):
+ """Tests for endpoint used from select_line
+
+ * /list_move_lines (to change order)
+ * /scan_source
+
+ """
+
+ def test_list_move_lines_order_by_location(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ # Ensure that the second location is ordered before the first one
+ # to avoid "false-positive" checks
+ self.zone_sublocation2.name = "a " + self.zone_sublocation2.name
+ response = self.service.dispatch(
+ "list_move_lines",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "order": "location",
+ },
+ )
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.location_id.name)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines,
+ )
+
+ def test_scan_source_wrong_parameters(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": 1234567890,
+ "picking_type_id": picking_type.id,
+ "barcode": self.zone_sublocation1.barcode,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": 1234567890,
+ "barcode": self.zone_sublocation1.barcode,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_scan_source_barcode_location_not_allowed(self):
+ """Scan source: scanned location not allowed."""
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.customer_location.barcode,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.location_not_allowed(),
+ )
+
+ def test_scan_source_barcode_location_one_move_line(self):
+ """Scan source: scanned location 'Zone sub-location 1' contains only
+ one move line, next step 'set_line_destination' expected.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.zone_sublocation1.barcode,
+ },
+ )
+ move_line = self.picking1.move_line_ids
+ self.assert_response_set_line_destination(
+ response,
+ zone_location=self.zone_location,
+ picking_type=self.picking_type,
+ move_line=move_line,
+ )
+
+ def test_scan_source_barcode_location_several_move_lines(self):
+ """Scan source: scanned location 'Zone sub-location 2' contains two
+ move lines, next step 'select_line' expected with the list of these
+ move lines.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.zone_sublocation2.barcode,
+ },
+ )
+ move_lines = self.picking2.move_line_ids
+ self.assert_response_select_line(
+ response,
+ zone_location=self.zone_sublocation2,
+ picking_type=self.picking_type,
+ move_lines=move_lines,
+ message=self.service.msg_store.several_lines_in_location(
+ self.zone_sublocation2
+ ),
+ )
+
+ def test_scan_source_barcode_package(self):
+ """Scan source: scanned package has one related move line,
+ next step 'set_line_destination' expected on it.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ package = self.picking1.package_level_ids[0].package_id
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": package.name,
+ },
+ )
+ move_lines = self.service._find_location_move_lines(
+ zone_location, picking_type, package=package,
+ )
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ move_line = move_lines[0]
+ self.assert_response_set_line_destination(
+ response,
+ zone_location=self.zone_location,
+ picking_type=self.picking_type,
+ move_line=move_line,
+ )
+
+ def test_scan_source_barcode_package_not_found(self):
+ """Scan source: scanned package has no related move line,
+ next step 'select_line' expected.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.free_package.name,
+ },
+ )
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ self.assert_response_select_line(
+ response,
+ zone_location=self.zone_location,
+ picking_type=self.picking_type,
+ move_lines=move_lines,
+ message=self.service.msg_store.package_not_found(),
+ )
+
+ def test_scan_source_barcode_product(self):
+ """Scan source: scanned product has one related move line,
+ next step 'set_line_destination' expected on it.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.product_a.barcode,
+ },
+ )
+ move_line = self.service._find_location_move_lines(
+ zone_location, picking_type, product=self.product_a,
+ )
+ self.assert_response_set_line_destination(
+ response,
+ zone_location=self.zone_location,
+ picking_type=self.picking_type,
+ move_line=move_line,
+ )
+
+ def test_scan_source_barcode_product_not_found(self):
+ """Scan source: scanned product has no related move line,
+ next step 'select_line' expected.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.free_product.barcode,
+ },
+ )
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ self.assert_response_select_line(
+ response,
+ zone_location=self.zone_location,
+ picking_type=self.picking_type,
+ move_lines=move_lines,
+ message=self.service.msg_store.product_not_found(),
+ )
+
+ def test_scan_source_barcode_lot(self):
+ """Scan source: scanned lot has one related move line,
+ next step 'set_line_destination' expected on it.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ lot = self.picking2.move_line_ids.lot_id[0]
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": lot.name,
+ },
+ )
+ move_lines = self.service._find_location_move_lines(
+ zone_location, picking_type, lot=lot,
+ )
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ move_line = move_lines[0]
+ self.assert_response_set_line_destination(
+ response,
+ zone_location=self.zone_location,
+ picking_type=self.picking_type,
+ move_line=move_line,
+ )
+
+ def test_scan_source_barcode_lot_not_found(self):
+ """Scan source: scanned lot has no related move line,
+ next step 'select_line' expected.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.free_lot.name,
+ },
+ )
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ self.assert_response_select_line(
+ response,
+ zone_location=self.zone_location,
+ picking_type=self.picking_type,
+ move_lines=move_lines,
+ message=self.service.msg_store.lot_not_found(),
+ )
+
+ def test_scan_source_barcode_not_found(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": "UNKNOWN",
+ },
+ )
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority)
+ self.assert_response_select_line(
+ response,
+ zone_location=self.zone_location,
+ picking_type=self.picking_type,
+ move_lines=move_lines,
+ message=self.service.msg_store.barcode_not_found(),
+ )
From cd0f64a7d24e2aa675bf0ba78dda448fce655311 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Tue, 21 Jul 2020 10:39:28 +0200
Subject: [PATCH 298/940] zone picking: implement /set_destination
+ change zero_check implementation (both for zone and cluster picking)
to only check the location stock status (all products combined).
---
shopfloor/actions/message.py | 24 +
shopfloor/models/__init__.py | 1 +
shopfloor/models/stock_location.py | 24 +
shopfloor/models/stock_move.py | 20 +
shopfloor/services/cluster_picking.py | 32 +-
.../services/location_content_transfer.py | 22 +-
shopfloor/services/zone_picking.py | 222 +++++++-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_cluster_picking_scan.py | 27 +-
shopfloor/tests/test_zone_picking_base.py | 99 +++-
.../test_zone_picking_set_line_destination.py | 519 ++++++++++++++++++
11 files changed, 908 insertions(+), 83 deletions(-)
create mode 100644 shopfloor/models/stock_move.py
create mode 100644 shopfloor/tests/test_zone_picking_set_line_destination.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 8c422b7e33..6a3be13450 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -89,6 +89,24 @@ def package_not_available_in_picking(self, package, picking):
),
}
+ def package_not_empty(self, package):
+ return {
+ "message_type": "warning",
+ "body": _("Package {} is not empty.").format(package.name),
+ }
+
+ def package_already_used(self, package):
+ return {
+ "message_type": "warning",
+ "body": _("Package {} is already used.").format(package.name),
+ }
+
+ def dest_package_required(self):
+ return {
+ "message_type": "warning",
+ "body": _("A destination package is required."),
+ }
+
def line_not_available_in_picking(self, picking):
return {
"message_type": "warning",
@@ -365,3 +383,9 @@ def location_empty(self, location):
"message_type": "info",
"body": _("Location {} empty").format(location.name),
}
+
+ def unable_to_pick_more(self, quantity):
+ return {
+ "message_type": "error",
+ "body": _("You must not pick more than {} units.").format(quantity),
+ }
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index 1bfd3ece74..05a95579d3 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -4,6 +4,7 @@
from . import shopfloor_profile
from . import stock_inventory
from . import stock_location
+from . import stock_move
from . import stock_move_line
from . import stock_package_level
from . import stock_picking
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
index 53eb9eb88b..db4672b573 100644
--- a/shopfloor/models/stock_location.py
+++ b/shopfloor/models/stock_location.py
@@ -1,4 +1,5 @@
from odoo import fields, models
+from odoo.tools.float_utils import float_compare
class StockLocation(models.Model):
@@ -38,3 +39,26 @@ def _get_reserved_move_lines(self):
def _compute_reserved_move_lines(self):
for rec in self:
rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()})
+
+ def planned_qty_in_location_is_empty(self):
+ """Return if a location will be empty when move lines will be confirmed
+
+ Used for the "zero check". We need to know if a location is empty, but since
+ we set the move lines to "done" only at the end of the unload workflow, we
+ have to look at the qty_done of the move lines from this location.
+ """
+ self.ensure_one()
+ quants = self.env["stock.quant"].search(
+ [("quantity", ">", 0), ("location_id", "=", self.id)]
+ )
+ remaining = sum(quants.mapped("quantity"))
+ lines = self.env["stock.move.line"].search(
+ [
+ ("state", "!=", "done"),
+ ("location_id", "=", self.id),
+ ("qty_done", ">", 0),
+ ]
+ )
+ planned = remaining - sum(lines.mapped("qty_done"))
+ compare = float_compare(planned, 0, precision_rounding=0.01)
+ return compare <= 0
diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py
new file mode 100644
index 0000000000..0194457f9d
--- /dev/null
+++ b/shopfloor/models/stock_move.py
@@ -0,0 +1,20 @@
+from odoo import models
+
+
+class StockMove(models.Model):
+ _inherit = "stock.move"
+
+ def split_other_move_lines(self, move_lines):
+ """Substract `move_lines` from `move.move_line_ids`, put the result
+ in a new move and returns it.
+ """
+ self.ensure_one()
+ other_move_lines = self.move_line_ids - move_lines
+ if other_move_lines:
+ qty_to_split = sum(other_move_lines.mapped("product_uom_qty"))
+ backorder_move_id = self._split(qty_to_split)
+ backorder_move = self.browse(backorder_move_id)
+ backorder_move.move_line_ids = other_move_lines
+ backorder_move._action_assign()
+ return backorder_move
+ return False
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index b065ad02f1..f7577f7d95 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -579,12 +579,7 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit
if qty_greater:
return self._response_for_scan_destination(
move_line,
- message={
- "message_type": "error",
- "body": _("You must not pick more than {} units.").format(
- move_line.product_uom_qty
- ),
- },
+ message=self.msg_store.unable_to_pick_more(move_line.product_uom_qty),
)
elif qty_lesser:
# split the move line which will be processed later (maybe the user
@@ -623,9 +618,7 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit
move_line.write({"qty_done": quantity, "result_package_id": bin_package.id})
zero_check = move_line.picking_id.picking_type_id.shopfloor_zero_check
- if zero_check and self._planned_qty_in_location_is_empty(
- move_line.product_id, move_line.location_id
- ):
+ if zero_check and move_line.location_id.planned_qty_in_location_is_empty():
return self._response_for_zero_check(batch, move_line)
return self._pick_next_line(
@@ -638,27 +631,6 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit
force_line=new_line,
)
- def _planned_qty_in_location_is_empty(self, product, location):
- """Return if a location will be empty when move lines will be confirmed
-
- Used for the "zero check". We need to know if a location is empty, but since
- we set the move lines to "done" only at the end of the unload workflow, we
- have to look at the qty_done of the move lines from this location.
- """
- remaining = product.with_context(location=location.id).qty_available
- lines_in_loc = self.env["stock.move.line"].search(
- # TODO do we care about lots here?
- [
- ("state", "!=", "done"),
- ("location_id", "=", location.id),
- ("product_id", "=", product.id),
- ]
- )
- planned = remaining - sum(lines_in_loc.mapped("qty_done"))
- rounding = product.uom_id.rounding
- compare = float_compare(planned, 0, precision_rounding=rounding)
- return compare <= 0
-
def _are_all_dest_location_same(self, batch):
lines_to_unload = self._lines_to_unload(batch)
return len(lines_to_unload.mapped("location_dest_id")) == 1
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index c3faf64454..02cfe4683d 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -317,20 +317,6 @@ def _set_destination_lines(self, pickings, move_lines, dest_location):
move_lines.package_level_id.location_dest_id = dest_location
pickings.action_done()
- def _split_other_move_lines(self, move, move_lines):
- """Substract `move_lines` from `move.move_line_ids` and put the result
- in a new move.
- """
- other_move_lines = move.move_line_ids - move_lines
- if other_move_lines:
- qty_to_split = sum(other_move_lines.mapped("product_uom_qty"))
- backorder_move_id = move._split(qty_to_split)
- backorder_move = self.env["stock.move"].browse(backorder_move_id)
- backorder_move.move_line_ids = other_move_lines
- backorder_move._action_assign()
- return backorder_move
- return False
-
def set_destination_all(self, location_id, barcode, confirmation=False):
"""Scan destination location for all the moves of the location
@@ -553,7 +539,7 @@ def set_destination_package(
# Check if there is no other lines linked to the move others than
# the lines related to the package itself. In such case we have to
# split the move to process only the lines related to the package.
- self._split_other_move_lines(package_move, package_move_lines)
+ package_move.split_other_move_lines(package_move_lines)
package_level.location_dest_id = scanned_location
package_moves.with_context(_sf_no_backorder=True)._action_done()
move_lines = self._find_transfer_move_lines(location)
@@ -625,7 +611,7 @@ def set_destination_line(
(new_move | current_move)._action_assign()
for remaining_move_line in current_move.move_line_ids:
remaining_move_line.qty_done = remaining_move_line.product_uom_qty
- self._split_other_move_lines(move_line.move_id, move_line)
+ move_line.move_id.split_other_move_lines(move_line)
move_line.location_dest_id = scanned_location
move_line.move_id.with_context(_sf_no_backorder=True)._action_done()
move_lines = self._find_transfer_move_lines(location)
@@ -695,7 +681,7 @@ def stock_out_package(self, location_id, package_level_id):
# Check if there is no other lines linked to the move others than
# the lines related to the package itself. In such case we have to
# split the move to process only the lines related to the package.
- self._split_other_move_lines(package_move, package_move_lines)
+ package_move.split_other_move_lines(package_move_lines)
lot = package_move.move_line_ids.lot_id
package_move._do_unreserve()
package_move._recompute_state()
@@ -747,7 +733,7 @@ def stock_out_line(self, location_id, move_line_id):
move_lines = self._find_transfer_move_lines(location)
return self._response_for_start_single(move_lines.mapped("picking_id"))
inventory = self.actions_for("inventory")
- self._split_other_move_lines(move_line.move_id, move_line)
+ move_line.move_id.split_other_move_lines(move_line)
move_line_src_location = move_line.location_id
move = move_line.move_id
package = move_line.package_id
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 2d015fc31a..167947ec3b 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1,4 +1,5 @@
from odoo.fields import first
+from odoo.tools.float_utils import float_compare
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
@@ -99,21 +100,19 @@ def _response_for_select_line(
)
def _response_for_set_line_destination(
- self, zone_location, picking_type, move_line, message=None
+ self,
+ zone_location,
+ picking_type,
+ move_line,
+ message=None,
+ confirmation_required=False,
):
+ if confirmation_required and not message:
+ message = self.msg_store.need_confirmation()
+ data = self._data_for_move_line(zone_location, picking_type, move_line)
+ data["confirmation_required"] = confirmation_required
return self._response(
- next_state="set_line_destination",
- data=self._data_for_move_line(zone_location, picking_type, move_line),
- message=message,
- )
-
- def _response_for_confirm_set_line_destination(
- self, zone_location, picking_type, move_line, message=None
- ):
- return self._response(
- next_state="confirm_set_line_destination",
- data=self._data_for_move_line(zone_location, picking_type, move_line),
- message=message,
+ next_state="set_line_destination", data=data, message=message,
)
def _response_for_zero_check(
@@ -121,7 +120,7 @@ def _response_for_zero_check(
):
return self._response(
next_state="zero_check",
- data=self._data_for_move_line(zone_location, picking_type, location),
+ data=self._data_for_location(zone_location, picking_type, location),
message=message,
)
@@ -200,6 +199,13 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines):
"move_lines": self.data.move_lines(move_lines),
}
+ def _data_for_location(self, zone_location, picking_type, location):
+ return {
+ "zone_location": self.data.location(zone_location),
+ "picking_type": self.data.picking_type(picking_type),
+ "location": self.data.location(location),
+ }
+
def _find_location_move_lines_domain(
self, location, picking_type=None, package=None, product=None, lot=None
):
@@ -421,6 +427,133 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit
zone_location, picking_type, move_line
)
+ def _set_destination_location(
+ self, zone_location, picking_type, move_line, quantity, confirmation, location
+ ):
+ # Ask confirmation to the user if the scanned location is not in the
+ # expected ones but is valid (in picking type's default destination)
+ if not location.is_sublocation_of(move_line.location_dest_id) and (
+ not confirmation
+ and location.is_sublocation_of(picking_type.default_location_dest_id)
+ ):
+ return self._response_for_set_line_destination(
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.msg_store.confirm_location_changed(
+ move_line.location_dest_id, location
+ ),
+ confirmation_required=True,
+ )
+ # A valid location is a sub-location of the original destination, or a
+ # sub-location of the picking type's default destination location if
+ # `confirmation is True
+ if not location.is_sublocation_of(move_line.location_dest_id) and (
+ confirmation
+ and not location.is_sublocation_of(picking_type.default_location_dest_id)
+ ):
+ return self._response_for_set_line_destination(
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.msg_store.dest_location_not_allowed(),
+ )
+ # If no destination package
+ if not move_line.result_package_id:
+ return self._response_for_set_line_destination(
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.msg_store.dest_package_required(),
+ )
+ # destination location set to the scanned one
+ move_line.location_dest_id = location
+ # the quantity done is set to the passed quantity
+ move_line.qty_done = quantity
+ # if the move has other move lines, it is split to have only this move line
+ move_line.move_id.split_other_move_lines(move_line)
+ # set to done (without backorder)
+ move_line.move_id.with_context(_sf_no_backorder=True)._action_done()
+ # try to re-assign any split move (in case of partial qty)
+ if "confirmed" in move_line.picking_id.move_lines.mapped("state"):
+ move_line.picking_id.action_assign()
+ # Zero check
+ zero_check = picking_type.shopfloor_zero_check
+ if zero_check and move_line.location_id.planned_qty_in_location_is_empty():
+ return self._response_for_zero_check(
+ zone_location, picking_type, move_line.location_id
+ )
+
+ def _is_package_empty(self, package):
+ return not bool(package.quant_ids)
+
+ def _is_package_already_used(self, package):
+ return bool(
+ self.env["stock.move.line"].search_count(
+ [
+ ("state", "not in", ("done", "cancel")),
+ ("result_package_id", "=", package.id),
+ ]
+ )
+ )
+
+ def _set_destination_package(
+ self, zone_location, picking_type, move_line, quantity, package
+ ):
+ # A valid package is:
+ # * an empty package
+ # * not used as destination for another move line
+ if not self._is_package_empty(package):
+ return self._response_for_set_line_destination(
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.msg_store.package_not_empty(package),
+ )
+ if self._is_package_already_used(package):
+ return self._response_for_set_line_destination(
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.msg_store.package_already_used(package),
+ )
+ # the quantity done is set to the passed quantity
+ # but if we move a partial qty, we need to split the move line
+ rounding = move_line.product_uom_id.rounding
+ compare = float_compare(
+ quantity, move_line.product_uom_qty, precision_rounding=rounding
+ )
+ qty_lesser = compare == -1
+ qty_greater = compare == 1
+ if qty_greater:
+ return self._response_for_set_line_destination(
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.msg_store.unable_to_pick_more(move_line.product_uom_qty),
+ )
+ elif qty_lesser:
+ # split the move line which will be processed later
+ remaining = move_line.product_uom_qty - quantity
+ move_line.copy({"product_uom_qty": remaining, "qty_done": 0})
+ # if we didn't bypass reservation update, the quant reservation
+ # would be reduced as much as the deduced quantity, which is wrong
+ # as we only moved the quantity to a new move line
+ move_line.with_context(
+ bypass_reservation_update=True
+ ).product_uom_qty = quantity
+ move_line.qty_done = quantity
+ # destination package is set to the scanned one
+ move_line.result_package_id = package
+ # the field ``shopfloor_user_id`` is updated with the current user
+ move_line.shopfloor_user_id = self.env.user
+ # Zero check
+ zero_check = picking_type.shopfloor_zero_check
+ if zero_check and move_line.location_id.planned_qty_in_location_is_empty():
+ return self._response_for_zero_check(
+ zone_location, picking_type, move_line.location_id
+ )
+
def set_destination(
self,
zone_location_id,
@@ -429,6 +562,7 @@ def set_destination(
barcode,
quantity,
confirmation=False,
+ order="priority",
):
"""Set a destination location (and done) or a destination package (in buffer)
@@ -473,12 +607,35 @@ def set_destination(
* confirm_set_line_destination: the scanned location is not in the
expected one but is valid (in picking type's default destination)
"""
- # TODO on _action_done, use ``_sf_no_backorder`` in the
- # context to disable backorders (see override in stock_picking.py).
- # NOTE for the implementation: zero_check is active only if the option
- # is active on the picking_type (maybe shopfloor.menu), check in
- # cluster picking how it's done
- return self._response()
+ zone_location = self.env["stock.location"].browse(zone_location_id)
+ if not zone_location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ picking_type = self.env["stock.picking.type"].browse(picking_type_id)
+ if not picking_type.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ move_line = self.env["stock.move.line"].browse(move_line_id)
+ if not move_line.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ search = self.actions_for("search")
+ # When the barcode is a location
+ location = search.location_from_scan(barcode)
+ if location:
+ response = self._set_destination_location(
+ zone_location, picking_type, move_line, quantity, confirmation, location
+ )
+ if response:
+ return response
+ # When the barcode is a package
+ package = search.package_from_scan(barcode)
+ if package:
+ location = move_line.location_dest_id
+ response = self._set_destination_package(
+ zone_location, picking_type, move_line, quantity, package
+ )
+ if response:
+ return response
+ # Process the next line
+ return self.list_move_lines(zone_location.id, picking_type.id)
def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero):
"""Confirm or not if the source location of a move has zero qty
@@ -695,6 +852,7 @@ def set_destination(self):
return {
"zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
"picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
"barcode": {"required": False, "nullable": True, "type": "string"},
"order": {
"required": False,
@@ -783,8 +941,7 @@ def _states(self):
"select_picking_type": self._schema_for_select_picking_type,
"select_line": self._schema_for_move_lines,
"set_line_destination": self._schema_for_move_line,
- "confirm_set_line_destination": self._schema_for_move_line,
- "zero_check": self._schema_for_move_line,
+ "zero_check": self._schema_for_zero_check,
"change_pack_lot": self._schema_for_move_line,
"unload_all": self._schema_for_move_lines,
"confirm_unload_all": self._schema_for_move_lines,
@@ -806,12 +963,7 @@ def scan_source(self):
def set_destination(self):
return self._response_schema(
- next_states={
- "select_line",
- "set_line_destination",
- "confirm_set_line_destination",
- "zero_check",
- }
+ next_states={"select_line", "set_line_destination", "zero_check"}
)
def is_zero(self):
@@ -876,6 +1028,11 @@ def _schema_for_move_line(self):
"zone_location": self.schemas._schema_dict_of(self.schemas.location()),
"picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()),
"move_line": self.schemas._schema_dict_of(self.schemas.move_line()),
+ "confirmation_required": {
+ "type": "boolean",
+ "nullable": True,
+ "required": False,
+ },
}
return schema
@@ -887,3 +1044,12 @@ def _schema_for_move_lines(self):
"move_lines": self.schemas._schema_list_of(self.schemas.move_line()),
}
return schema
+
+ @property
+ def _schema_for_zero_check(self):
+ schema = {
+ "zone_location": self.schemas._schema_dict_of(self.schemas.location()),
+ "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()),
+ "location": self.schemas._schema_dict_of(self.schemas.location()),
+ }
+ return schema
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 90a93e7bbe..82123a252d 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -42,3 +42,4 @@
from . import test_zone_picking_start
from . import test_zone_picking_select_picking_type
from . import test_zone_picking_select_line
+from . import test_zone_picking_set_line_destination
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 3c097a4ee8..5f550f7205 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -515,15 +515,36 @@ def test_scan_destination_pack_quantity_less(self):
def test_scan_destination_pack_zero_check_activated(self):
"""Location will be emptied, have to go to zero check"""
+ # ensure that the location used for the test will contain only what we want
+ self.zero_check_location = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "ZeroCheck",
+ "location_id": self.stock_location.id,
+ "barcode": "ZEROCHECK",
+ }
+ )
+ )
line = self.one_line_picking.move_line_ids
+ location, product, qty = (
+ self.zero_check_location,
+ line.product_id,
+ line.product_uom_qty,
+ )
+ self.one_line_picking.do_unreserve()
+
# ensure we have activated the zero check
self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = True
# Update the quantity in the location to be equal to the line's
# so when scan_destination_pack sets the qty_done, the planned
# qty should be zero and trigger a zero check
- self._update_qty_in_location(
- line.location_id, line.product_id, line.product_uom_qty
- )
+ self._update_qty_in_location(location, product, qty)
+ # Reserve goods (now the move line has the expected source location)
+ self.one_line_picking.move_lines.location_id = location
+ self.one_line_picking.action_assign()
+ line = self.one_line_picking.move_line_ids
response = self.service.dispatch(
"scan_destination_pack",
params={
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index ed857bb349..d0b18cba63 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -13,6 +13,7 @@ def setUpClassVars(cls, *args, **kwargs):
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
+ cls.packing_location.sudo().active = True
# We want to limit the tests to a dedicated location in Stock/ to not
# be bothered with pickings brought by demo data
cls.zone_location = (
@@ -48,7 +49,48 @@ def setUpClassBaseData(cls, *args, **kwargs):
}
)
)
- products = cls.product_a + cls.product_b + cls.product_c + cls.product_d
+ cls.zone_sublocation3 = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone sub-location 3",
+ "location_id": cls.zone_location.id,
+ "barcode": "ZONE_SUBLOCATION_3",
+ }
+ )
+ )
+ cls.zone_sublocation4 = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone sub-location 4",
+ "location_id": cls.zone_location.id,
+ "barcode": "ZONE_SUBLOCATION_4",
+ }
+ )
+ )
+ cls.product_e = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product E",
+ "type": "product",
+ "default_code": "E",
+ "barcode": "E",
+ "weight": 3,
+ }
+ )
+ )
+ products = (
+ cls.product_a
+ + cls.product_b
+ + cls.product_c
+ + cls.product_d
+ + cls.product_e
+ )
for product in products:
cls.env["stock.putaway.rule"].sudo().create(
{
@@ -62,13 +104,21 @@ def setUpClassBaseData(cls, *args, **kwargs):
cls.picking2 = picking2 = cls._create_picking(
lines=[(cls.product_b, 10), (cls.product_c, 10)]
)
- cls.pickings = picking1 | picking2
+ cls.picking3 = picking3 = cls._create_picking(lines=[(cls.product_d, 10)])
+ cls.picking4 = picking4 = cls._create_picking(lines=[(cls.product_e, 10)])
+ cls.pickings = picking1 | picking2 | picking3 | picking4
cls._fill_stock_for_moves(
picking1.move_lines, in_package=True, location=cls.zone_sublocation1
)
cls._fill_stock_for_moves(
picking2.move_lines, in_lot=True, location=cls.zone_sublocation2
)
+ cls._fill_stock_for_moves(picking3.move_lines, location=cls.zone_sublocation3)
+ # Put product_e quantities in two different source locations to get
+ # two stock move lines (6 and 4 to satisfy 10 qties)
+ cls._update_qty_in_location(cls.zone_sublocation3, cls.product_e, 6)
+ cls._update_qty_in_location(cls.zone_sublocation4, cls.product_e, 4)
+ # cls._fill_stock_for_moves(picking4.move_lines, location=cls.zone_sublocation3)
cls.pickings.action_assign()
# Some records not related at all to the processed move lines
cls.free_package = cls.env["stock.quant.package"].create(
@@ -161,7 +211,14 @@ def assert_response_select_line(
)
def _assert_response_set_line_destination(
- self, state, response, zone_location, picking_type, move_line, message=None,
+ self,
+ state,
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=None,
+ confirmation_required=False,
):
self.assert_response(
response,
@@ -170,12 +227,19 @@ def _assert_response_set_line_destination(
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
"move_line": self.data.move_line(move_line),
+ "confirmation_required": confirmation_required,
},
message=message,
)
def assert_response_set_line_destination(
- self, response, zone_location, picking_type, move_line, message=None,
+ self,
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=None,
+ confirmation_required=False,
):
self._assert_response_set_line_destination(
"set_line_destination",
@@ -184,4 +248,31 @@ def assert_response_set_line_destination(
picking_type,
move_line,
message=message,
+ confirmation_required=confirmation_required,
+ )
+
+ def _assert_response_zero_check(
+ self, state, response, zone_location, picking_type, location, message=None,
+ ):
+ self.assert_response(
+ response,
+ next_state=state,
+ data={
+ "zone_location": self.data.location(zone_location),
+ "picking_type": self.data.picking_type(picking_type),
+ "location": self.data.location(location),
+ },
+ message=message,
+ )
+
+ def assert_response_zero_check(
+ self, response, zone_location, picking_type, location, message=None,
+ ):
+ self._assert_response_zero_check(
+ "zero_check",
+ response,
+ zone_location,
+ picking_type,
+ location,
+ message=message,
)
diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py
new file mode 100644
index 0000000000..88568e70fd
--- /dev/null
+++ b/shopfloor/tests/test_zone_picking_set_line_destination.py
@@ -0,0 +1,519 @@
+from .test_zone_picking_base import ZonePickingCommonCase
+
+
+class ZonePickingSetLineDestinationCase(ZonePickingCommonCase):
+ """Tests for endpoint used from set_line_destination
+
+ * /set_destination
+
+ """
+
+ def test_set_destination_wrong_parameters(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": 1234567890,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": False,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": 1234567890,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": False,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": 1234567890,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": False,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_set_destination_location_confirm(self):
+ """Scanned barcode is the destination location but needs confirmation
+ as it is outside the current move line destination but is still
+ allowed by the picking type's default destination.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ move_line.location_dest_id = self.shelf1
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": False,
+ },
+ )
+ # Check response
+ self.assert_response_set_line_destination(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.confirm_location_changed(
+ move_line.location_dest_id, self.packing_location
+ ),
+ confirmation_required=True,
+ )
+ # Confirm the destination with a wrong destination (should not happen)
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.customer_location.barcode,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": True,
+ },
+ )
+ # Check response
+ self.assert_response_set_line_destination(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.dest_location_not_allowed(),
+ )
+ # Confirm the destination with the right destination this time
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": True,
+ },
+ )
+ # Check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines,
+ )
+
+ def test_set_destination_location_no_other_move_line_full_qty(self):
+ """Scanned barcode is the destination location.
+
+ The move line is the only one in the move, and we move the whole qty.
+
+ Initial data:
+
+ move qty 10 (assigned):
+ -> move_line qty 10 from location X
+
+ Then the operator move the 10 qty, we get:
+
+ move qty 10 (done):
+ -> move_line qty 10 from location X
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ moves_before = self.picking1.move_lines
+ self.assertEqual(len(moves_before), 1)
+ self.assertEqual(len(moves_before.move_line_ids), 1)
+ move_line = moves_before.move_line_ids
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": False,
+ },
+ )
+ # Check picking data
+ moves_after = self.picking1.move_lines
+ self.assertEqual(moves_before, moves_after)
+ self.assertEqual(move_line.qty_done, 10)
+ # Check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines
+ )
+
+ def test_set_destination_location_no_other_move_line_partial_qty(self):
+ """Scanned barcode is the destination location.
+
+ The move line is the only one in the move, and we move some of the qty.
+
+ Initial data:
+
+ move qty 10 (assigned):
+ -> move_line qty 10 from location X
+
+ Then the operator move 6 qty on 10, we get:
+
+ move qty 6 (done):
+ -> move_line qty 6 from location X
+ move qty 4 (assigned):
+ -> move_line qty 4 from location Y (remaining)
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking3.picking_type_id
+ moves_before = self.picking3.move_lines
+ self.assertEqual(len(moves_before), 1)
+ self.assertEqual(len(moves_before.move_line_ids), 1)
+ move_line = moves_before.move_line_ids
+ # we need a destination package if we want to scan a destination location
+ move_line.result_package_id = self.free_package
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": 6,
+ "confirmation": False,
+ },
+ )
+ # Check picking data (move has been split in two, 6 done and 4 remaining)
+ moves_after = self.picking3.move_lines
+ self.assertEqual(len(moves_after), 2)
+ self.assertEqual(moves_after[0].product_uom_qty, 6)
+ self.assertEqual(moves_after[0].state, "done")
+ self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0)
+ self.assertEqual(moves_after[1].product_uom_qty, 4)
+ self.assertEqual(moves_after[1].state, "assigned")
+ self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4)
+ self.assertEqual(move_line.qty_done, 6)
+ # Check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines
+ )
+
+ def test_set_destination_location_several_move_line_full_qty(self):
+ """Scanned barcode is the destination location.
+
+ The move line has siblings in the move, and we move the whole qty:
+ the processed move line will then get its own move (split from original one)
+
+ Initial data:
+
+ move qty 10 (assigned):
+ -> move_line qty 6 from location X
+ -> move_line qty 4 from location Y
+
+ Then the operator move 6 qty (from the first move line), we get:
+
+ move qty 6 (done):
+ -> move_line qty 4 from location X
+ move qty 4 (assigned):
+ -> move_line qty 4 from location Y (untouched)
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking4.picking_type_id
+ moves_before = self.picking4.move_lines
+ self.assertEqual(len(moves_before), 1)
+ self.assertEqual(len(moves_before.move_line_ids), 2)
+ move_line = moves_before.move_line_ids[0]
+ # we need a destination package if we want to scan a destination location
+ move_line.result_package_id = self.free_package
+ other_move_line = moves_before.move_line_ids[1]
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty, # 6 qty
+ "confirmation": False,
+ },
+ )
+ # Check picking data (move has been split in two, 6 done and 4 remaining)
+ moves_after = self.picking4.move_lines
+ self.assertEqual(len(moves_after), 2)
+ self.assertEqual(moves_after[0].product_uom_qty, 6)
+ self.assertEqual(moves_after[0].state, "done")
+ self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0)
+ self.assertEqual(moves_after[1].product_uom_qty, 4)
+ self.assertEqual(moves_after[1].state, "assigned")
+ self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4)
+ self.assertEqual(move_line.qty_done, 6)
+ self.assertNotEqual(move_line.move_id, other_move_line.move_id)
+ # Check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines
+ )
+
+ def test_set_destination_location_several_move_line_partial_qty(self):
+ """Scanned barcode is the destination location.
+
+ The move line has siblings in the move, and we move some of the qty:
+ the processed move line will then get its own move (split from original one)
+
+ Initial data:
+
+ move qty 10 (assigned):
+ -> move_line qty 6 from location X
+ -> move_line qty 4 from location Y
+
+ Then the operator move 4 qty on 6 (from the first move line), we get:
+
+ move qty 4 (done):
+ -> move_line qty 4 from location X
+ move qty 2 (assigned):
+ -> move_line qty 2 from location X (remaining)
+ move qty 4 (assigned):
+ -> move_line qty 4 from location Y (untouched)
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking4.picking_type_id
+ moves_before = self.picking4.move_lines
+ self.assertEqual(len(moves_before), 1) # 10 qty
+ self.assertEqual(len(moves_before.move_line_ids), 2) # 6+4 qty
+ move_line = moves_before.move_line_ids[0]
+ # we need a destination package if we want to scan a destination location
+ move_line.result_package_id = self.free_package
+ other_move_line = moves_before.move_line_ids[1]
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": 4, # 4/6 qty
+ "confirmation": False,
+ },
+ )
+ # Check picking data (move has been split in three, 4 done, 2+4 remaining)
+ moves_after = self.picking4.move_lines
+ self.assertEqual(len(moves_after), 3)
+ self.assertEqual(moves_after[0].product_uom_qty, 4)
+ self.assertEqual(moves_after[0].state, "done")
+ self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0)
+ self.assertEqual(moves_after[1].product_uom_qty, 4)
+ self.assertEqual(moves_after[1].state, "assigned")
+ self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4)
+ self.assertEqual(moves_after[2].product_uom_qty, 2)
+ self.assertEqual(moves_after[2].state, "assigned")
+ self.assertEqual(moves_after[2].move_line_ids.product_uom_qty, 2)
+ self.assertEqual(move_line.qty_done, 4)
+ self.assertNotEqual(move_line.move_id, other_move_line.move_id)
+ # Check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines
+ )
+
+ def test_set_destination_location_zero_check(self):
+ """Scanned barcode is the destination location.
+
+ The move line is the only one in the source location, as such the
+ 'zero_check' step is triggered.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ picking_type.sudo().shopfloor_zero_check = True
+ self.assertEqual(len(self.picking1.move_line_ids), 1)
+ move_line = self.picking1.move_line_ids
+ location_is_empty = move_line.location_id.planned_qty_in_location_is_empty
+ self.assertFalse(location_is_empty())
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": False,
+ },
+ )
+ self.assertTrue(location_is_empty())
+ # Check response
+ self.assert_response_zero_check(
+ response, zone_location, picking_type, move_line.location_id,
+ )
+
+ def test_set_destination_package_full_qty(self):
+ """Scanned barcode is the destination package.
+
+ Initial data:
+
+ move qty 10 (assigned):
+ -> move_line qty 10 from location X
+
+ Then the operator move the 10 qty, we get:
+
+ move qty 10 (done):
+ -> move_line qty 10 from location X with the scanned package
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ moves_before = self.picking1.move_lines
+ self.assertEqual(len(moves_before), 1)
+ self.assertEqual(len(moves_before.move_line_ids), 1)
+ move_line = moves_before.move_line_ids
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.free_package.name,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": False,
+ },
+ )
+ # Check picking data
+ moves_after = self.picking1.move_lines
+ self.assertEqual(moves_before, moves_after)
+ self.assertRecordValues(
+ move_line,
+ [
+ {
+ "result_package_id": self.free_package.id,
+ "product_uom_qty": 10,
+ "qty_done": 10,
+ "shopfloor_user_id": self.env.user.id,
+ },
+ ],
+ )
+ # Check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines
+ )
+
+ def test_set_destination_package_partial_qty(self):
+ """Scanned barcode is the destination package.
+
+ Initial data:
+
+ move qty 10 (assigned):
+ -> move_line qty 10 from location X
+
+ Then the operator move the 6 on 10 qty, we get:
+
+ move qty 6 (assigned):
+ -> move_line qty 6 from location X with the scanned package (buffer)
+ -> move_line qty 4 from location X (remaining)
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ moves_before = self.picking1.move_lines
+ self.assertEqual(len(moves_before), 1)
+ self.assertEqual(len(moves_before.move_line_ids), 1)
+ move_line = moves_before.move_line_ids
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.free_package.name,
+ "quantity": 6,
+ "confirmation": False,
+ },
+ )
+ # Check picking data
+ moves_after = self.picking1.move_lines
+ new_move_line = self.picking1.move_line_ids.filtered(
+ lambda line: line != move_line
+ )
+ self.assertTrue(move_line != new_move_line)
+ self.assertEqual(moves_before, moves_after)
+ self.assertRecordValues(
+ move_line,
+ [
+ {
+ "result_package_id": self.free_package.id,
+ "product_uom_qty": 6,
+ "qty_done": 6,
+ "shopfloor_user_id": self.env.user.id,
+ },
+ ],
+ )
+ self.assertRecordValues(
+ new_move_line,
+ [
+ {
+ "result_package_id": new_move_line.package_id.id, # Unchanged
+ "product_uom_qty": 4,
+ "qty_done": 0,
+ "shopfloor_user_id": False,
+ },
+ ],
+ )
+ # Check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines
+ )
+
+ def test_set_destination_package_zero_check(self):
+ """Scanned barcode is the destination package.
+
+ The move line is the only one in the source location, as such the
+ 'zero_check' step is triggered.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ picking_type.sudo().shopfloor_zero_check = True
+ self.assertEqual(len(self.picking1.move_line_ids), 1)
+ move_line = self.picking1.move_line_ids
+ location_is_empty = move_line.location_id.planned_qty_in_location_is_empty
+ self.assertFalse(location_is_empty())
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.free_package.name,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": False,
+ },
+ )
+ self.assertTrue(location_is_empty())
+ # Check response
+ self.assert_response_zero_check(
+ response, zone_location, picking_type, move_line.location_id,
+ )
From 1a6d2fe45d3d571f79beb65429b365fde921c6d0 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 27 Jul 2020 11:07:50 +0200
Subject: [PATCH 299/940] zone picking: implement /is_zero
---
shopfloor/services/zone_picking.py | 24 +++-
shopfloor/tests/__init__.py | 1 +
.../tests/test_zone_picking_zero_check.py | 103 ++++++++++++++++++
3 files changed, 126 insertions(+), 2 deletions(-)
create mode 100644 shopfloor/tests/test_zone_picking_zero_check.py
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 167947ec3b..35d61bbdc8 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -648,8 +648,28 @@ def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero):
* select_line: whether the user confirms or not the location is empty,
go back to the picking of lines
"""
- # TODO look in cluster_picking.py, same function exists
- return self._response()
+ zone_location = self.env["stock.location"].browse(zone_location_id)
+ if not zone_location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ picking_type = self.env["stock.picking.type"].browse(picking_type_id)
+ if not picking_type.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ move_line = self.env["stock.move.line"].browse(move_line_id)
+ if not move_line.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ if not zero:
+ inventory = self.actions_for("inventory")
+ inventory.create_draft_check_empty(
+ move_line.location_id,
+ # FIXME as zero_check is done on the whole location, we should
+ # create an inventory on it without the product critera
+ # => the same applies on "Cluster Picking" scenario
+ # move_line.product_id,
+ move_line.product_id.browse(), # HACK send an empty recordset
+ ref=picking_type.name,
+ )
+ move_lines = self._find_location_move_lines(zone_location, picking_type)
+ return self._response_for_select_line(zone_location, picking_type, move_lines)
def stock_issue(self, zone_location_id, picking_type_id, move_line_id):
"""Declare a stock issue for a line
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 82123a252d..62f5b637e5 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -43,3 +43,4 @@
from . import test_zone_picking_select_picking_type
from . import test_zone_picking_select_line
from . import test_zone_picking_set_line_destination
+from . import test_zone_picking_zero_check
diff --git a/shopfloor/tests/test_zone_picking_zero_check.py b/shopfloor/tests/test_zone_picking_zero_check.py
new file mode 100644
index 0000000000..dba09b2c70
--- /dev/null
+++ b/shopfloor/tests/test_zone_picking_zero_check.py
@@ -0,0 +1,103 @@
+from .test_zone_picking_base import ZonePickingCommonCase
+
+
+class ZonePickingZeroCheckCase(ZonePickingCommonCase):
+ """Tests for endpoint used from zero_check
+
+ * /is_zero
+
+ """
+
+ def test_is_zero_wrong_parameters(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ response = self.service.dispatch(
+ "is_zero",
+ params={
+ "zone_location_id": 1234567890,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "zero": True,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "is_zero",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": 1234567890,
+ "move_line_id": move_line.id,
+ "zero": True,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "is_zero",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": 1234567890,
+ "zero": True,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_is_zero_is_empty(self):
+ """call /is_zero confirming it's empty"""
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ response = self.service.dispatch(
+ "is_zero",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "zero": True,
+ },
+ )
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines,
+ )
+
+ def test_is_zero_is_not_empty(self):
+ """call /is_zero not confirming it's empty"""
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ response = self.service.dispatch(
+ "is_zero",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "zero": False,
+ },
+ )
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines,
+ )
+ inventory = self.env["stock.inventory"].search(
+ [
+ ("location_ids", "in", move_line.location_id.id),
+ # FIXME check 'is_zero' implementation
+ # ("product_ids", "in", move_line.product_id.id),
+ ("state", "=", "draft"),
+ ]
+ )
+ self.assertTrue(inventory)
+ self.assertEqual(
+ inventory.name,
+ "Zero check issue on location {} ({})".format(
+ move_line.location_id.name, picking_type.name,
+ ),
+ )
From bf7b5131cb684f32f019b29909b66a24b38db5bc Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 27 Jul 2020 13:17:22 +0200
Subject: [PATCH 300/940] zone picking: implement /stock_issue
---
shopfloor/services/zone_picking.py | 66 +++++-
shopfloor/tests/__init__.py | 1 +
.../tests/test_zone_picking_stock_issue.py | 193 ++++++++++++++++++
3 files changed, 258 insertions(+), 2 deletions(-)
create mode 100644 shopfloor/tests/test_zone_picking_stock_issue.py
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 35d61bbdc8..227d50c2e5 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -671,6 +671,26 @@ def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero):
move_lines = self._find_location_move_lines(zone_location, picking_type)
return self._response_for_select_line(zone_location, picking_type, move_lines)
+ def _domain_stock_issue_unlink_lines(self, move_line):
+ # Since we have not enough stock, delete the move lines, which will
+ # in turn unreserve the moves. The moves lines we delete are those
+ # in the same location, and not yet started.
+ # The goal is to prevent the same operator to declare twice the same
+ # stock issue for the same product/lot/package.
+ move = move_line.move_id
+ lot = move_line.lot_id
+ package = move_line.package_id
+ location = move_line.location_id
+ domain = [
+ ("location_id", "=", location.id),
+ ("product_id", "=", move.product_id.id),
+ ("package_id", "=", package.id),
+ ("lot_id", "=", lot.id),
+ ("state", "not in", ("cancel", "done")),
+ ("qty_done", "=", 0),
+ ]
+ return domain
+
def stock_issue(self, zone_location_id, picking_type_id, move_line_id):
"""Declare a stock issue for a line
@@ -699,8 +719,50 @@ def stock_issue(self, zone_location_id, picking_type_id, move_line_id):
* set_line_destination: something could be reserved instead of the original
move line
"""
- # TODO look in cluster_picking.py, similar function exists
- return self._response()
+ zone_location = self.env["stock.location"].browse(zone_location_id)
+ if not zone_location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ picking_type = self.env["stock.picking.type"].browse(picking_type_id)
+ if not picking_type.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ move_line = self.env["stock.move.line"].browse(move_line_id)
+ if not move_line.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ inventory = self.actions_for("inventory")
+ # create a draft inventory for a user to check
+ inventory.create_control_stock(
+ move_line.location_id,
+ move_line.product_id,
+ move_line.package_id,
+ move_line.lot_id,
+ )
+ move = move_line.move_id
+ lot = move_line.lot_id
+ package = move_line.package_id
+ location = move_line.location_id
+
+ # unreserve every lines for the same product/lot in the same location and
+ # not done yet, so the same user doesn't have to declare 2 times the
+ # stock issue for the same thing!
+ domain = self._domain_stock_issue_unlink_lines(move_line)
+ unreserve_move_lines = move_line | self.env["stock.move.line"].search(domain)
+ unreserve_moves = unreserve_move_lines.mapped("move_id").sorted()
+ unreserve_move_lines.unlink()
+
+ # Then, create an inventory with just enough qty so the other assigned
+ # move lines for the same product in other batches and the other move lines
+ # already picked stay assigned.
+ inventory.create_stock_issue(move, location, package, lot)
+
+ # try to reassign the moves in case we have stock in another location
+ unreserve_moves._action_assign()
+
+ if move.move_line_ids:
+ return self._response_for_set_line_destination(
+ zone_location, picking_type, move.move_line_ids[0]
+ )
+ move_lines = self._find_location_move_lines(zone_location, picking_type)
+ return self._response_for_select_line(zone_location, picking_type, move_lines)
def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barcode):
"""Change the source package or the lot of a move line
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 62f5b637e5..373328a6fc 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -44,3 +44,4 @@
from . import test_zone_picking_select_line
from . import test_zone_picking_set_line_destination
from . import test_zone_picking_zero_check
+from . import test_zone_picking_stock_issue
diff --git a/shopfloor/tests/test_zone_picking_stock_issue.py b/shopfloor/tests/test_zone_picking_stock_issue.py
new file mode 100644
index 0000000000..3767f02905
--- /dev/null
+++ b/shopfloor/tests/test_zone_picking_stock_issue.py
@@ -0,0 +1,193 @@
+from .test_zone_picking_base import ZonePickingCommonCase
+
+
+class ZonePickingStockIssueCase(ZonePickingCommonCase):
+ """Tests for endpoint used from stock_issue
+
+ * /stock_issue
+
+ """
+
+ def test_stock_issue_wrong_parameters(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ response = self.service.dispatch(
+ "stock_issue",
+ params={
+ "zone_location_id": 1234567890,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "stock_issue",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": 1234567890,
+ "move_line_id": move_line.id,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "stock_issue",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": 1234567890,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_stock_issue_no_more_reservation(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ move = move_line.move_id
+ response = self.service.dispatch(
+ "stock_issue",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ },
+ )
+ self.assertFalse(move_line.exists())
+ self.assertFalse(move.move_line_ids)
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines,
+ )
+
+ def test_stock_issue1(self):
+ """Once the stock issue is done, the move can't be reserved anymore."""
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ location = move_line.location_id
+ move = move_line.move_id
+ response = self.service.dispatch(
+ "stock_issue",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ },
+ )
+ self.assertFalse(move_line.exists())
+ self.assertFalse(move.move_line_ids)
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines,
+ )
+ # Check that the inventory exists
+ inventory = self.env["stock.inventory"].search(
+ [
+ (
+ "name",
+ "ilike",
+ "{} stock correction in location {}".format(
+ move.picking_id.name, location.name
+ ),
+ ),
+ ("state", "=", "done"),
+ ("line_ids.location_id", "in", location.ids),
+ ("line_ids.product_id", "in", move.product_id.ids),
+ ]
+ )
+ self.assertTrue(inventory)
+ self.assertEqual(inventory.line_ids.product_id, move.product_id)
+ self.assertEqual(inventory.line_ids.product_qty, 0)
+
+ def test_stock_issue2(self):
+ """Once the stock issue is done, the move has been reserved again."""
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ location = move_line.location_id
+ move = move_line.move_id
+ # Increase the quantity in the current location
+ self._update_qty_in_location(location, move.product_id, 100)
+ response = self.service.dispatch(
+ "stock_issue",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ },
+ )
+ self.assertFalse(move_line.exists())
+ self.assertTrue(move.move_line_ids)
+ self.assertEqual(move.move_line_ids.location_id, location)
+ self.assert_response_set_line_destination(
+ response, zone_location, picking_type, move.move_line_ids,
+ )
+ # Check the inventory
+ inventory = self.env["stock.inventory"].search(
+ [
+ (
+ "name",
+ "ilike",
+ "{} stock correction in location {}".format(
+ move.picking_id.name, location.name
+ ),
+ ),
+ ("state", "=", "done"),
+ ("line_ids.location_id", "in", location.ids),
+ ("line_ids.product_id", "in", move.product_id.ids),
+ ]
+ )
+ self.assertTrue(inventory)
+ self.assertEqual(inventory.line_ids.product_id, move.product_id)
+ self.assertEqual(inventory.line_ids.product_qty, 0)
+
+ def test_stock_issue3(self):
+ """Once the stock issue is done, the move has been reserved again
+ but from another location.
+ """
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ location = move_line.location_id
+ move = move_line.move_id
+ # Put some quantity in another location to get a new reservations from there
+ self._update_qty_in_location(self.zone_sublocation2, move.product_id, 10)
+ response = self.service.dispatch(
+ "stock_issue",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ },
+ )
+ self.assertFalse(move_line.exists())
+ self.assertTrue(move.move_line_ids)
+ self.assertEqual(move.move_line_ids.location_id, self.zone_sublocation2)
+ self.assert_response_set_line_destination(
+ response, zone_location, picking_type, move.move_line_ids,
+ )
+ # Check the inventory
+ inventory = self.env["stock.inventory"].search(
+ [
+ (
+ "name",
+ "ilike",
+ "{} stock correction in location {}".format(
+ move.picking_id.name, location.name
+ ),
+ ),
+ ("state", "=", "done"),
+ ("line_ids.location_id", "in", location.ids),
+ ("line_ids.product_id", "in", move.product_id.ids),
+ ]
+ )
+ self.assertTrue(inventory)
+ self.assertEqual(inventory.line_ids.product_id, move.product_id)
+ self.assertEqual(inventory.line_ids.product_qty, 0)
From 0561d0f013c7c57c07c448f328414ef65c107573 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Tue, 28 Jul 2020 17:49:01 +0200
Subject: [PATCH 301/940] zone picking: implement /change_pack_lot
---
shopfloor/actions/message.py | 40 ++-
shopfloor/services/change_pack_lot_mixin.py | 165 +++++++++++
shopfloor/services/cluster_picking.py | 185 +------------
shopfloor/services/zone_picking.py | 54 +++-
shopfloor/tests/__init__.py | 1 +
.../test_cluster_picking_change_pack_lot.py | 79 ++----
shopfloor/tests/test_zone_picking_base.py | 26 ++
.../test_zone_picking_change_pack_lot.py | 258 ++++++++++++++++++
8 files changed, 572 insertions(+), 236 deletions(-)
create mode 100644 shopfloor/services/change_pack_lot_mixin.py
create mode 100644 shopfloor/tests/test_zone_picking_change_pack_lot.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 6a3be13450..0f72b69ecd 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -187,10 +187,16 @@ def several_packs_in_location(self, location):
),
}
+ def no_package_or_lot_for_barcode(self, barcode):
+ return {
+ "message_type": "error",
+ "body": _("No package or lot found for barcode {}.").format(barcode),
+ }
+
def no_lot_for_barcode(self, barcode):
return {
"message_type": "error",
- "body": _("No lot found for {}".format(barcode)),
+ "body": _("No lot found for {}").format(barcode),
}
def lot_on_wrong_product(self, barcode):
@@ -202,7 +208,7 @@ def lot_on_wrong_product(self, barcode):
def several_lots_in_location(self, location):
return {
"message_type": "warning",
- "body": _("Several lots found in %s, please scan a lot." % location.name),
+ "body": _("Several lots found in %s, please scan a lot.") % location.name,
}
def several_products_in_location(self, location):
@@ -216,7 +222,7 @@ def several_products_in_location(self, location):
def no_pending_operation_for_pack(self, pack):
return {
"message_type": "error",
- "body": _("No pending operation for package %s." % pack.name),
+ "body": _("No pending operation for package %s.") % pack.name,
}
def unrecoverable_error(self):
@@ -389,3 +395,31 @@ def unable_to_pick_more(self, quantity):
"message_type": "error",
"body": _("You must not pick more than {} units.").format(quantity),
}
+
+ def lot_replaced_by_lot(self, old_lot, new_lot):
+ return {
+ "message_type": "success",
+ "body": _("Lot {} replaced by lot {}.").format(old_lot.name, new_lot.name),
+ }
+
+ def package_replaced_by_package(self, old_package, new_package):
+ return {
+ "message_type": "success",
+ "body": _("Package {} replaced by package {}.").format(
+ old_package.name, new_package.name
+ ),
+ }
+
+ def package_already_picked_by(self, package, picking):
+ return {
+ "message_type": "error",
+ "body": _(
+ _("Package {} cannot be picked, already moved by transfer {}.")
+ ).format(package.name, picking.name),
+ }
+
+ def lot_is_not_a_package(self, lot):
+ return {
+ "message_type": "error",
+ "body": _("Lot {} is not a package.").format(lot.name),
+ }
diff --git a/shopfloor/services/change_pack_lot_mixin.py b/shopfloor/services/change_pack_lot_mixin.py
new file mode 100644
index 0000000000..bf6cb12f75
--- /dev/null
+++ b/shopfloor/services/change_pack_lot_mixin.py
@@ -0,0 +1,165 @@
+from odoo import _
+
+
+class ChangePackLotMixin:
+ def _change_lot(self, move_line, lot, response_ok_func, response_error_func):
+ """Change the lot on the move line.
+
+ :param response_ok_func: callable used tu return ok response
+ :param response_error_func: callable used tu return error response
+ """
+ # If the lot is part of a package, what we really want
+ # is not to change the lot, but change the package (which will
+ # in turn change the lot altogether), but we have to pay attention
+ # to some things:
+ # * cannot replace a package by a lot without package (qty may be
+ # different, ...)
+ # * if we have several packages for the same lot, we can't know which
+ # one the operator is moving, ask to scan a package
+ lot_package_quants = self.env["stock.quant"].search(
+ [
+ ("lot_id", "=", lot.id),
+ ("location_id", "=", move_line.location_id.id),
+ ("package_id", "!=", False),
+ ("quantity", ">", 0),
+ ]
+ )
+ if move_line.package_id and not lot_package_quants:
+ return response_error_func(
+ move_line, message=self.msg_store.lot_is_not_a_package(lot),
+ )
+ if len(lot_package_quants) == 1:
+ package = lot_package_quants.package_id
+ return self._change_pack_lot_change_package(
+ move_line, package, response_ok_func, response_error_func
+ )
+ elif len(lot_package_quants) > 1:
+ return response_error_func(
+ move_line,
+ message=self.msg_store.several_packs_in_location(move_line.location_id),
+ )
+ return self._change_pack_lot_change_lot(
+ move_line, lot, response_ok_func, response_error_func
+ )
+
+ def _change_pack_lot_change_lot(
+ self, move_line, lot, response_ok_func, response_error_func
+ ):
+ inventory = self.actions_for("inventory")
+ product = move_line.product_id
+ if lot.product_id != product:
+ return response_error_func(
+ move_line, message=self.msg_store.lot_on_wrong_product(lot.name)
+ )
+ previous_lot = move_line.lot_id
+ # Changing the lot on the move line updates the reservation on the quants
+ move_line.lot_id = lot
+
+ message = self.msg_store.lot_replaced_by_lot(previous_lot, lot)
+ # check that we are supposed to have enough of this lot in the source location
+ quant = lot.quant_ids.filtered(lambda q: q.location_id == move_line.location_id)
+ if not quant:
+ # not supposed to have this lot here... (if there is a quant
+ # but not enough quantity we don't care here: user will report
+ # a stock issue)
+ inventory.create_control_stock(
+ move_line.location_id,
+ move_line.product_id,
+ move_line.package_id,
+ move_line.lot_id,
+ _("Pick: stock issue on lot: {} found in {}").format(
+ lot.name, move_line.location_id.name
+ ),
+ )
+ message["body"] += _(" A draft inventory has been created for control.")
+
+ return response_ok_func(move_line, message=message)
+
+ def _package_identical_move_lines_qty(self, package, move_lines):
+ grouped_quants = {}
+ for quant in package.quant_ids:
+ grouped_quants.setdefault(quant.product_id, 0)
+ grouped_quants[quant.product_id] += quant.quantity
+
+ grouped_lines = {}
+ for move_line in move_lines:
+ grouped_lines.setdefault(move_line.product_id, 0)
+ grouped_lines[move_line.product_id] += move_line.product_uom_qty
+
+ return grouped_quants == grouped_lines
+
+ def _change_pack_lot_change_package(
+ self, move_line, package, response_ok_func, response_error_func
+ ):
+ inventory = self.actions_for("inventory")
+
+ package_level = move_line.package_level_id
+ # several move lines can be moved by the package level, we'll have
+ # to update all of them
+ move_lines = package_level.move_line_ids
+
+ # prevent to replace a package by a package with a different content
+ identical_content = self._package_identical_move_lines_qty(package, move_lines)
+ if not identical_content:
+ return response_error_func(
+ move_line, message=self.msg_store.package_different_content(package)
+ )
+
+ previous_package = move_line.package_id
+
+ if package.location_id != move_line.location_id:
+ # the package has been scanned in the current location so we know its
+ # a mistake in the data... fix the quant to move the package here
+ inventory.move_package_quants_to_location(package, move_line.location_id)
+
+ # search a package level which would already move the scanned package
+ reserved_level = (
+ self.env["stock.package_level"].search([("package_id", "=", package.id)])
+ # not possible to search on state
+ .filtered(lambda level: level.state in ("new", "assigned"))
+ )
+ if reserved_level:
+ reserved_level.ensure_one()
+ if reserved_level.is_done:
+ # Not really supposed to happen: if someone sets is_done, the package
+ # should no longer be here! But we have to check this and inform the
+ # user in any case.
+ return response_error_func(
+ move_line,
+ message=self.msg_store.package_already_picked_by(
+ package, reserved_level.picking_id
+ ),
+ )
+
+ # Switch the package with the level which was moving it, as we know
+ # that:
+ # * only one package level at a time is supposed to move a package
+ # * the content of the other package is the same (as we checked the
+ # content is the same as the current move lines)
+ # * if we left the reserved level with the scanned package, we would
+ # have 2 levels for the same package and odoo would unreserve the
+ # move lines as soon as we confirm the current moves
+ # Considering this, we should be safe to interchange the packages
+ if reserved_level:
+ # Ignore updates on quant reservation, which would prevent to switch
+ # 2 packages between 2 assigned package levels: when writing the
+ # package of the second level to the first level, it would unreserve
+ # it because the second level is still using the package.
+ # But here, we know they both available before and must be available after!
+ reserved_level.with_context(bypass_reservation_update=True).replace_package(
+ previous_package
+ )
+ package_level.with_context(bypass_reservation_update=True).replace_package(
+ package
+ )
+ else:
+ # when we are not switching packages, we expect the quant
+ # reservations to be aligned
+ package_level.replace_package(package)
+
+ return response_ok_func(
+ move_line,
+ message=self.msg_store.package_replaced_by_package(
+ previous_package, package
+ ),
+ )
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index f7577f7d95..0871afb452 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -5,10 +5,11 @@
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
+from .change_pack_lot_mixin import ChangePackLotMixin
from .service import to_float
-class ClusterPicking(Component):
+class ClusterPicking(Component, ChangePackLotMixin):
"""
Methods for the Cluster Picking Process
@@ -881,186 +882,24 @@ def change_pack_lot(self, picking_batch_id, move_line_id, barcode):
batch, message=self.msg_store.operation_not_found()
)
search = self.actions_for("search")
+ response_ok_func = self._response_for_scan_destination
+ response_error_func = self._response_for_change_pack_lot
lot = search.lot_from_scan(barcode)
if lot:
- # If the lot is part of a package, what we really want
- # is not to change the lot, but change the package (which will
- # in turn change the lot altogether), but we have to pay attention
- # to some things:
- # * cannot replace a package by a lot without package (qty may be
- # different, ...)
- # * if we have several packages for the same lot, we can't know which
- # one the operator is moving, ask to scan a package
- lot_package_quants = self.env["stock.quant"].search(
- [
- ("lot_id", "=", lot.id),
- ("location_id", "=", move_line.location_id.id),
- ("package_id", "!=", False),
- ("quantity", ">", 0),
- ]
+ response = self._change_lot(
+ move_line, lot, response_ok_func, response_error_func
)
- if move_line.package_id and not lot_package_quants:
- return self._response_for_change_pack_lot(
- move_line,
- message={
- "message_type": "error",
- "body": _("Lot {} is not a package.").format(lot.name),
- },
- )
-
- if len(lot_package_quants) == 1:
- package = lot_package_quants.package_id
- return self._change_pack_lot_change_package(move_line, package)
- elif len(lot_package_quants) > 1:
- return self._response_for_change_pack_lot(
- move_line,
- message=self.msg_store.several_packs_in_location(
- move_line.location_id
- ),
- )
-
- return self._change_pack_lot_change_lot(move_line, lot)
+ if response:
+ return response
package = search.package_from_scan(barcode)
if package:
- return self._change_pack_lot_change_package(move_line, package)
-
- return self._response_for_change_pack_lot(
- move_line,
- message={
- "message_type": "warning",
- "body": _("No package or lot found for barcode {}").format(barcode),
- },
- )
-
- def _change_pack_lot_change_lot(self, move_line, lot):
- inventory = self.actions_for("inventory")
- product = move_line.product_id
- if lot.product_id != product:
- return self._response_for_change_pack_lot(
- move_line, message=self.msg_store.lot_on_wrong_product(lot.name)
+ return self._change_pack_lot_change_package(
+ move_line, package, response_ok_func, response_error_func
)
- previous_lot = move_line.lot_id
- # Changing the lot on the move line updates the reservation
- # on the quants
- move_line.lot_id = lot
-
- success_body = _("Lot {} replaced by lot {}.").format(
- previous_lot.name, lot.name
- )
- # check that we are supposed to have enough of this lot in the
- # source location
- quant = lot.quant_ids.filtered(lambda q: q.location_id == move_line.location_id)
- if not quant:
- # not supposed to have this lot here... (if there is a quant
- # but not enough quantity we don't care here: user will report
- # a stock issue)
- inventory.create_control_stock(
- move_line.location_id,
- move_line.product_id,
- move_line.package_id,
- move_line.lot_id,
- _("Pick: stock issue on lot: {} found in {}").format(
- lot.name, move_line.location_id.name
- ),
- )
- success_body += _(" A draft inventory has been created for control.")
-
- return self._response_for_scan_destination(
- move_line, message={"message_type": "success", "body": success_body}
- )
-
- def _package_identical_move_lines_qty(self, package, move_lines):
- grouped_quants = {}
- for quant in package.quant_ids:
- grouped_quants.setdefault(quant.product_id, 0)
- grouped_quants[quant.product_id] += quant.quantity
- grouped_lines = {}
- for move_line in move_lines:
- grouped_lines.setdefault(move_line.product_id, 0)
- grouped_lines[move_line.product_id] += move_line.product_uom_qty
-
- return grouped_quants == grouped_lines
-
- def _change_pack_lot_change_package(self, move_line, package):
- inventory = self.actions_for("inventory")
-
- package_level = move_line.package_level_id
- # several move lines can be moved by the package level, we'll have
- # to update all of them
- move_lines = package_level.move_line_ids
-
- # prevent to replace a package by a package with a different content
- identical_content = self._package_identical_move_lines_qty(package, move_lines)
- if not identical_content:
- return self._response_for_change_pack_lot(
- move_line, message=self.msg_store.package_different_content(package)
- )
-
- previous_package = move_line.package_id
-
- if package.location_id != move_line.location_id:
- # the package has been scanned in the current location so we know its
- # a mistake in the data... fix the quant to move the package here
- inventory.move_package_quants_to_location(package, move_line.location_id)
-
- # search a package level which would already move the scanned package
- reserved_level = (
- self.env["stock.package_level"].search([("package_id", "=", package.id)])
- # not possible to search on state
- .filtered(lambda level: level.state in ("new", "assigned"))
- )
- if reserved_level:
- reserved_level.ensure_one()
- if reserved_level.is_done:
- # Not really supposed to happen: if someone sets is_done, the package
- # should no longer be here! But we have to check this and inform the
- # user in any case.
- return self._response_for_change_pack_lot(
- move_line,
- message={
- "message_type": "error",
- "body": _(
- "Package {} cannot be picked, already moved by transfer {}"
- ).format(package.name, reserved_level.picking_id.name),
- },
- )
-
- # Switch the package with the level which was moving it, as we know
- # that:
- # * only one package level at a time is supposed to move a package
- # * the content of the other package is the same (as we checked the
- # content is the same as the current move lines)
- # * if we left the reserved level with the scanned package, we would
- # have 2 levels for the same package and odoo would unreserve the
- # move lines as soon as we confirm the current moves
- # Considering this, we should be safe to interchange the packages
- if reserved_level:
- # Ignore updates on quant reservation, which would prevent to switch
- # 2 packages between 2 assigned package levels: when writing the
- # package of the second level to the first level, it would unreserve
- # it because the second level is still using the package.
- # But here, we know they both available before and must be available after!
- reserved_level.with_context(bypass_reservation_update=True).replace_package(
- previous_package
- )
- package_level.with_context(bypass_reservation_update=True).replace_package(
- package
- )
- else:
- # when we are not switching packages, we expect the quant
- # reservations to be aligned
- package_level.replace_package(package)
-
- return self._response_for_scan_destination(
- move_line,
- message={
- "message_type": "success",
- "body": _("Package {} replaced by package {}").format(
- previous_package.name, package.name
- ),
- },
+ return self._response_for_change_pack_lot(
+ move_line, message=self.msg_store.no_package_or_lot_for_barcode(barcode),
)
def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 227d50c2e5..1c51b2f3cb 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1,13 +1,16 @@
+import functools
+
from odoo.fields import first
from odoo.tools.float_utils import float_compare
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
+from .change_pack_lot_mixin import ChangePackLotMixin
from .service import to_float
-class ZonePicking(Component):
+class ZonePicking(Component, ChangePackLotMixin):
"""
Methods for the Zone Picking Process
@@ -780,8 +783,53 @@ def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barco
moved to destination now
* select_line: if the move line does not exist anymore
"""
- # TODO look in cluster_picking.py, similar function exists
- return self._response()
+ zone_location = self.env["stock.location"].browse(zone_location_id)
+ if not zone_location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ picking_type = self.env["stock.picking.type"].browse(picking_type_id)
+ if not picking_type.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ move_line = self.env["stock.move.line"].browse(move_line_id)
+ if not move_line.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ search = self.actions_for("search")
+ # pre-configured callable used to generate the response as 'ChangePackLotMixin'
+ # is not aware of the needed response type and related parameters for
+ # zone picking scenario
+ response_ok_func = functools.partial(
+ self._response_for_set_line_destination, zone_location, picking_type
+ )
+ response_error_func = functools.partial(
+ self._response_for_change_pack_lot, zone_location, picking_type
+ )
+ response = None
+ # handle lot
+ lot = search.lot_from_scan(barcode)
+ if lot:
+ response = self._change_lot(
+ move_line, lot, response_ok_func, response_error_func
+ )
+ # handle package
+ package = search.package_from_scan(barcode)
+ if package:
+ response = self._change_pack_lot_change_package(
+ move_line, package, response_ok_func, response_error_func
+ )
+ # if the response is not an error, we check the move_line status
+ # to adapt the response ('set_line_destination' or 'select_line')
+ # TODO not sure to understand how 'move_line' could not exist here?
+ if response and response["message"]["message_type"] == "success":
+ # TODO adapt the response based on the move_line.exists()
+ if move_line.exists():
+ return response
+ return response
+
+ return self._response_for_change_pack_lot(
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.msg_store.no_package_or_lot_for_barcode(barcode),
+ )
def prepare_unload(self, zone_location_id, picking_type_id):
"""Initiate the unloading of the buffer
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 373328a6fc..2dea4bfe54 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -45,3 +45,4 @@
from . import test_zone_picking_set_line_destination
from . import test_zone_picking_zero_check
from . import test_zone_picking_stock_issue
+from . import test_zone_picking_change_pack_lot
diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
index f68772726b..1f924f10b3 100644
--- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py
+++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
@@ -129,12 +129,9 @@ def test_change_pack_lot_change_pack_ok(self):
line,
new_package.name,
success=True,
- message={
- "message_type": "success",
- "body": "Package {} replaced by package {}".format(
- initial_package.name, new_package.name
- ),
- },
+ message=self.service.msg_store.package_replaced_by_package(
+ initial_package, new_package
+ ),
)
self.assertRecordValues(
@@ -169,12 +166,9 @@ def test_change_pack_lot_change_pack_different_location(self):
line,
new_package.name,
success=True,
- message={
- "message_type": "success",
- "body": "Package {} replaced by package {}".format(
- initial_package.name, new_package.name
- ),
- },
+ message=self.service.msg_store.package_replaced_by_package(
+ initial_package, new_package
+ ),
)
self.assertRecordValues(
@@ -207,12 +201,9 @@ def test_change_pack_lot_change_lot_in_package_ok(self):
line,
new_lot.name,
success=True,
- message={
- "message_type": "success",
- "body": "Package {} replaced by package {}".format(
- initial_package.name, new_package.name
- ),
- },
+ message=self.service.msg_store.package_replaced_by_package(
+ initial_package, new_package
+ ),
)
self.assertRecordValues(
@@ -252,11 +243,7 @@ def test_change_pack_lot_change_lot_in_several_packages_error(self):
line,
new_lot.name,
success=False,
- message={
- "message_type": "warning",
- "body": "Several packages found in {},"
- " please scan a package.".format(self.shelf1.name),
- },
+ message=self.service.msg_store.several_packs_in_location(self.shelf1),
)
def test_change_pack_lot_change_lot_from_package_error(self):
@@ -277,10 +264,7 @@ def test_change_pack_lot_change_lot_from_package_error(self):
line,
new_lot.name,
success=False,
- message={
- "message_type": "error",
- "body": "Lot {} is not a package.".format(new_lot.name),
- },
+ message=self.service.msg_store.lot_is_not_a_package(new_lot),
)
def test_change_pack_lot_change_lot_ok(self):
@@ -296,12 +280,7 @@ def test_change_pack_lot_change_lot_ok(self):
line,
new_lot.name,
success=True,
- message={
- "message_type": "success",
- "body": "Lot {} replaced by lot {}.".format(
- initial_lot.name, new_lot.name
- ),
- },
+ message=self.service.msg_store.lot_replaced_by_lot(initial_lot, new_lot),
)
self.assertRecordValues(line, [{"lot_id": new_lot.id}])
@@ -318,15 +297,10 @@ def test_change_pack_lot_change_lot_different_location_ok(self):
new_lot = self._create_lot(self.product_a)
# ensure we have our new package in a different location
self._update_qty_in_location(self.shelf2, line.product_id, 10, lot=new_lot)
+ message = self.service.msg_store.lot_replaced_by_lot(initial_lot, new_lot)
+ message["body"] += " A draft inventory has been created for control."
self._test_change_pack_lot(
- line,
- new_lot.name,
- success=True,
- message={
- "message_type": "success",
- "body": "Lot {} replaced by lot {}. A draft inventory has"
- " been created for control.".format(initial_lot.name, new_lot.name),
- },
+ line, new_lot.name, success=True, message=message,
)
self.assertRecordValues(line, [{"lot_id": new_lot.id}])
@@ -394,10 +368,7 @@ def test_change_pack_lot_change_pack_different_content_error(self):
lines[0],
new_package.name,
success=False,
- message={
- "message_type": "error",
- "body": "Package {} has a different content.".format(new_package.name),
- },
+ message=self.service.msg_store.package_different_content(new_package),
)
def test_change_pack_lot_change_pack_multi_content_with_lot(self):
@@ -438,12 +409,9 @@ def test_change_pack_lot_change_pack_multi_content_with_lot(self):
lines[0],
new_package.name,
success=True,
- message={
- "message_type": "success",
- "body": "Package {} replaced by package {}".format(
- initial_package.name, new_package.name
- ),
- },
+ message=self.service.msg_store.package_replaced_by_package(
+ initial_package, new_package
+ ),
)
self.assertRecordValues(
@@ -493,12 +461,9 @@ def test_change_pack_lot_change_pack_steal_from_other_move_line(self):
line,
package2.name,
success=True,
- message={
- "message_type": "success",
- "body": "Package {} replaced by package {}".format(
- package1.name, package2.name
- ),
- },
+ message=self.service.msg_store.package_replaced_by_package(
+ package1, package2
+ ),
)
self.assertRecordValues(
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index d0b18cba63..79d22c8ea7 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -276,3 +276,29 @@ def assert_response_zero_check(
location,
message=message,
)
+
+ def _assert_response_change_pack_lot(
+ self, state, response, zone_location, picking_type, move_line, message=None,
+ ):
+ self.assert_response(
+ response,
+ next_state=state,
+ data={
+ "zone_location": self.data.location(zone_location),
+ "picking_type": self.data.picking_type(picking_type),
+ "move_line": self.data.move_line(move_line),
+ },
+ message=message,
+ )
+
+ def assert_response_change_pack_lot(
+ self, response, zone_location, picking_type, move_line, message=None,
+ ):
+ self._assert_response_change_pack_lot(
+ "change_pack_lot",
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=message,
+ )
diff --git a/shopfloor/tests/test_zone_picking_change_pack_lot.py b/shopfloor/tests/test_zone_picking_change_pack_lot.py
new file mode 100644
index 0000000000..06f6047342
--- /dev/null
+++ b/shopfloor/tests/test_zone_picking_change_pack_lot.py
@@ -0,0 +1,258 @@
+from .test_zone_picking_base import ZonePickingCommonCase
+
+
+class ZonePickingChangePackLotCase(ZonePickingCommonCase):
+ """Tests for endpoint used from change_pack_lot
+
+ * /change_pack_lot
+
+ """
+
+ def test_change_pack_lot_wrong_parameters(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ response = self.service.dispatch(
+ "change_pack_lot",
+ params={
+ "zone_location_id": 1234567890,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.free_lot.name,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "change_pack_lot",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": 1234567890,
+ "move_line_id": move_line.id,
+ "barcode": self.free_lot.name,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "change_pack_lot",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": 1234567890,
+ "barcode": self.free_lot.name,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_change_pack_lot_no_package_or_lot_for_barcode(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ barcode = "UNKNOWN"
+ response = self.service.dispatch(
+ "change_pack_lot",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": barcode,
+ },
+ )
+ self.assert_response_change_pack_lot(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.no_package_or_lot_for_barcode(barcode),
+ )
+
+ def test_change_pack_lot_change_pack_ok(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids[0]
+ previous_package = move_line.package_id
+ # ensure we have our new package in the same location
+ self._update_qty_in_location(
+ move_line.location_id,
+ move_line.product_id,
+ move_line.product_uom_qty,
+ package=self.free_package,
+ )
+ # change package
+ response = self.service.dispatch(
+ "change_pack_lot",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.free_package.name,
+ },
+ )
+ # check data
+ self.assertRecordValues(
+ move_line,
+ [
+ {
+ "package_id": self.free_package.id,
+ "result_package_id": self.free_package.id,
+ }
+ ],
+ )
+ self.assertRecordValues(
+ move_line.package_level_id, [{"package_id": self.free_package.id}]
+ )
+ # check that reservations have been updated
+ previous_quant = self.env["stock.quant"].search(
+ [
+ ("location_id", "=", move_line.location_id.id),
+ ("product_id", "=", move_line.product_id.id),
+ ("package_id", "=", previous_package.id),
+ ]
+ )
+ self.assertEqual(previous_quant.quantity, 10)
+ self.assertEqual(previous_quant.reserved_quantity, 0)
+ new_quant = self.env["stock.quant"].search(
+ [
+ ("location_id", "=", move_line.location_id.id),
+ ("product_id", "=", move_line.product_id.id),
+ ("package_id", "=", self.free_package.id),
+ ]
+ )
+ self.assertEqual(new_quant.quantity, 10)
+ self.assertEqual(new_quant.reserved_quantity, 10)
+ # check response
+ self.assert_response_set_line_destination(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.package_replaced_by_package(
+ previous_package, self.free_package
+ ),
+ )
+
+ def test_change_pack_lot_change_lot_ok(self):
+ zone_location = self.zone_location
+ picking_type = self.picking2.picking_type_id
+ move_line = self.picking2.move_line_ids[0]
+ previous_lot = move_line.lot_id
+ self.free_lot.product_id = move_line.product_id
+ # ensure we have our new lot in the same location
+ self._update_qty_in_location(
+ move_line.location_id,
+ move_line.product_id,
+ move_line.product_uom_qty,
+ lot=self.free_lot,
+ )
+ # change lot
+ response = self.service.dispatch(
+ "change_pack_lot",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.free_lot.name,
+ },
+ )
+ # check data
+ self.assertRecordValues(move_line, [{"lot_id": self.free_lot.id}])
+ # check that reservations have been updated
+ previous_quant = self.env["stock.quant"].search(
+ [
+ ("location_id", "=", move_line.location_id.id),
+ ("product_id", "=", move_line.product_id.id),
+ ("lot_id", "=", previous_lot.id),
+ ]
+ )
+ self.assertEqual(previous_quant.quantity, 10)
+ self.assertEqual(previous_quant.reserved_quantity, 0)
+ new_quant = self.env["stock.quant"].search(
+ [
+ ("location_id", "=", move_line.location_id.id),
+ ("product_id", "=", move_line.product_id.id),
+ ("lot_id", "=", self.free_lot.id),
+ ]
+ )
+ self.assertEqual(new_quant.quantity, 10)
+ self.assertEqual(new_quant.reserved_quantity, 10)
+ # check response
+ self.assert_response_set_line_destination(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.lot_replaced_by_lot(
+ previous_lot, self.free_lot
+ ),
+ )
+
+ def test_change_pack_lot_change_lot_ok_with_control_stock(self):
+ zone_location = self.zone_location
+ picking_type = self.picking2.picking_type_id
+ move_line = self.picking2.move_line_ids[0]
+ previous_lot = move_line.lot_id
+ self.free_lot.product_id = move_line.product_id
+ # ensure we have our new lot but in another location
+ self._update_qty_in_location(
+ self.zone_sublocation1,
+ move_line.product_id,
+ move_line.product_uom_qty,
+ lot=self.free_lot,
+ )
+ # change lot
+ response = self.service.dispatch(
+ "change_pack_lot",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.free_lot.name,
+ },
+ )
+ # check data
+ self.assertRecordValues(move_line, [{"lot_id": self.free_lot.id}])
+ # check that reservations could not be made as the lot is
+ # theoretically elsewhere
+ previous_quant = self.env["stock.quant"].search(
+ [
+ ("location_id", "=", move_line.location_id.id),
+ ("product_id", "=", move_line.product_id.id),
+ ("lot_id", "=", previous_lot.id),
+ ]
+ )
+ self.assertEqual(previous_quant.quantity, 10)
+ self.assertEqual(previous_quant.reserved_quantity, 0)
+ new_quant = self.env["stock.quant"].search(
+ [
+ ("location_id", "=", move_line.location_id.id),
+ ("product_id", "=", move_line.product_id.id),
+ ("lot_id", "=", self.free_lot.id),
+ ]
+ )
+ self.assertFalse(new_quant)
+ # as such an inventory of control has been generated to check this issue
+ control_inventory_name = "Pick: stock issue on lot: {} found in {}".format(
+ self.free_lot.name, move_line.location_id.name
+ )
+ control_inventory = self.env["stock.inventory"].search(
+ [
+ ("name", "=", control_inventory_name),
+ ("location_ids", "in", move_line.location_id.id),
+ ("product_ids", "in", move_line.product_id.id),
+ ("state", "in", ("draft", "confirm")),
+ ]
+ )
+ self.assertTrue(control_inventory)
+ # check response
+ message = self.service.msg_store.lot_replaced_by_lot(
+ previous_lot, self.free_lot
+ )
+ message["body"] += " A draft inventory has been created for control."
+ self.assert_response_set_line_destination(
+ response, zone_location, picking_type, move_line, message=message,
+ )
From ae720169d75bf826879bd6a628e8ba71f6b7049c Mon Sep 17 00:00:00 2001
From: sebalix
Date: Wed, 29 Jul 2020 16:17:55 +0200
Subject: [PATCH 302/940] zone picking: implement /prepare_unload
---
shopfloor/services/zone_picking.py | 46 ++++++-
shopfloor/tests/test_zone_picking_base.py | 100 +++++++++++++++-
.../tests/test_zone_picking_select_line.py | 113 +++++++++++++++++-
3 files changed, 254 insertions(+), 5 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 1c51b2f3cb..d12ee07ad2 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -260,6 +260,29 @@ def _sort_key_move_lines(order):
False,
)
+ def _find_buffer_move_lines_domain(self, zone_location, picking_type):
+ return [
+ ("location_id", "child_of", zone_location.id),
+ ("qty_done", ">", 0),
+ ("state", "not in", ("cancel", "done")),
+ ("result_package_id", "!=", False),
+ ("shopfloor_user_id", "=", self.env.user.id),
+ ]
+
+ def _find_buffer_move_lines(self, zone_location, picking_type):
+ """Find lines that belongs to the operator's buffer and return them
+ grouped by destination package.
+ """
+ domain = self._find_buffer_move_lines_domain(zone_location, picking_type)
+ return self.env["stock.move.line"].search(domain)
+
+ def _group_buffer_move_lines_by_package(self, move_lines):
+ data = {}
+ for move_line in move_lines:
+ data.setdefault(move_line.result_package_id, move_line.browse())
+ data[move_line.result_package_id] |= move_line
+ return data
+
def scan_location(self, barcode):
"""Scan the zone location where the picking should occur
@@ -858,7 +881,28 @@ def prepare_unload(self, zone_location_id, picking_type_id):
destination location
* select_line: no remaining move lines in buffer
"""
- return self._response()
+ zone_location = self.env["stock.location"].browse(zone_location_id)
+ if not zone_location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ picking_type = self.env["stock.picking.type"].browse(picking_type_id)
+ if not picking_type.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ move_lines = self._find_buffer_move_lines(zone_location, picking_type)
+ location_dest = move_lines.mapped("location_dest_id")
+ if len(move_lines) == 1:
+ return self._response_for_unload_set_destination(
+ zone_location, picking_type, move_lines
+ )
+ elif len(move_lines) > 1 and len(location_dest) == 1:
+ return self._response_for_unload_all(
+ zone_location, picking_type, move_lines
+ )
+ elif len(move_lines) > 1 and len(location_dest) > 1:
+ return self._response_for_unload_single(
+ zone_location, picking_type, first(move_lines)
+ )
+ move_lines = self._find_location_move_lines(zone_location, picking_type,)
+ return self._response_for_select_line(zone_location, picking_type, move_lines)
def set_destination_all(
self, zone_location_id, picking_type_id, barcode, confirmation=False
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 79d22c8ea7..8031c291da 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -84,12 +84,26 @@ def setUpClassBaseData(cls, *args, **kwargs):
}
)
)
+ cls.product_f = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product F",
+ "type": "product",
+ "default_code": "F",
+ "barcode": "F",
+ "weight": 3,
+ }
+ )
+ )
products = (
cls.product_a
+ cls.product_b
+ cls.product_c
+ cls.product_d
+ cls.product_e
+ + cls.product_f
)
for product in products:
cls.env["stock.putaway.rule"].sudo().create(
@@ -106,7 +120,10 @@ def setUpClassBaseData(cls, *args, **kwargs):
)
cls.picking3 = picking3 = cls._create_picking(lines=[(cls.product_d, 10)])
cls.picking4 = picking4 = cls._create_picking(lines=[(cls.product_e, 10)])
- cls.pickings = picking1 | picking2 | picking3 | picking4
+ cls.picking5 = picking5 = cls._create_picking(
+ lines=[(cls.product_b, 10), (cls.product_f, 10)]
+ )
+ cls.pickings = picking1 | picking2 | picking3 | picking4 | picking5
cls._fill_stock_for_moves(
picking1.move_lines, in_package=True, location=cls.zone_sublocation1
)
@@ -114,6 +131,9 @@ def setUpClassBaseData(cls, *args, **kwargs):
picking2.move_lines, in_lot=True, location=cls.zone_sublocation2
)
cls._fill_stock_for_moves(picking3.move_lines, location=cls.zone_sublocation3)
+ cls._fill_stock_for_moves(
+ picking5.move_lines, in_package=True, location=cls.zone_sublocation4
+ )
# Put product_e quantities in two different source locations to get
# two stock move lines (6 and 4 to satisfy 10 qties)
cls._update_qty_in_location(cls.zone_sublocation3, cls.product_e, 6)
@@ -302,3 +322,81 @@ def assert_response_change_pack_lot(
move_line,
message=message,
)
+
+ def _assert_response_unload_set_destination(
+ self, state, response, zone_location, picking_type, move_line, message=None,
+ ):
+ self.assert_response(
+ response,
+ next_state=state,
+ data={
+ "zone_location": self.data.location(zone_location),
+ "picking_type": self.data.picking_type(picking_type),
+ "move_line": self.data.move_line(move_line),
+ },
+ message=message,
+ )
+
+ def assert_response_unload_set_destination(
+ self, response, zone_location, picking_type, move_line, message=None,
+ ):
+ self._assert_response_unload_set_destination(
+ "unload_set_destination",
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=message,
+ )
+
+ def _assert_response_unload_all(
+ self, state, response, zone_location, picking_type, move_lines, message=None,
+ ):
+ self.assert_response(
+ response,
+ next_state=state,
+ data={
+ "zone_location": self.data.location(zone_location),
+ "picking_type": self.data.picking_type(picking_type),
+ "move_lines": self.data.move_lines(move_lines),
+ },
+ message=message,
+ )
+
+ def assert_response_unload_all(
+ self, response, zone_location, picking_type, move_lines, message=None,
+ ):
+ self._assert_response_unload_all(
+ "unload_all",
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=message,
+ )
+
+ def _assert_response_unload_single(
+ self, state, response, zone_location, picking_type, move_line, message=None,
+ ):
+ self.assert_response(
+ response,
+ next_state=state,
+ data={
+ "zone_location": self.data.location(zone_location),
+ "picking_type": self.data.picking_type(picking_type),
+ "move_line": self.data.move_line(move_line),
+ },
+ message=message,
+ )
+
+ def assert_response_unload_single(
+ self, response, zone_location, picking_type, move_line, message=None,
+ ):
+ self._assert_response_unload_single(
+ "unload_single",
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=message,
+ )
diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py
index e9142500cc..fc442b851e 100644
--- a/shopfloor/tests/test_zone_picking_select_line.py
+++ b/shopfloor/tests/test_zone_picking_select_line.py
@@ -6,6 +6,7 @@ class ZonePickingSelectLineCase(ZonePickingCommonCase):
* /list_move_lines (to change order)
* /scan_source
+ * /prepare_unload
"""
@@ -23,8 +24,9 @@ def test_list_move_lines_order_by_location(self):
"order": "location",
},
)
- move_lines = self.service._find_location_move_lines(zone_location, picking_type)
- move_lines = move_lines.sorted(lambda l: l.location_id.name)
+ move_lines = self.service._find_location_move_lines(
+ zone_location, picking_type, order="location"
+ )
self.assert_response_select_line(
response, zone_location, picking_type, move_lines,
)
@@ -281,7 +283,6 @@ def test_scan_source_barcode_not_found(self):
},
)
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
- move_lines = move_lines.sorted(lambda l: l.move_id.priority)
self.assert_response_select_line(
response,
zone_location=self.zone_location,
@@ -289,3 +290,109 @@ def test_scan_source_barcode_not_found(self):
move_lines=move_lines,
message=self.service.msg_store.barcode_not_found(),
)
+
+ def test_prepare_unload_wrong_parameters(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "prepare_unload",
+ params={
+ "zone_location_id": 1234567890,
+ "picking_type_id": picking_type.id,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "prepare_unload",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": 1234567890,
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_prepare_unload_buffer_empty(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ # unload goods
+ response = self.service.dispatch(
+ "prepare_unload",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ },
+ )
+ # check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines,
+ )
+
+ def test_prepare_unload_buffer_one_line(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ # scan a destination package to get something in the buffer
+ move_line = self.picking1.move_line_ids
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.free_package.name,
+ "quantity": move_line.product_uom_qty,
+ },
+ )
+ # unload goods
+ response = self.service.dispatch(
+ "prepare_unload",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ },
+ )
+ # check response
+ self.assert_response_unload_set_destination(
+ response, zone_location, picking_type, move_line,
+ )
+
+ def test_prepare_unload_buffer_multi_line_same_destination(self):
+ zone_location = self.zone_location
+ picking_type = self.picking5.picking_type_id
+ # scan a destination package for some move lines
+ # to get several lines in the buffer (which have the same destination)
+ self.another_package = self.env["stock.quant.package"].create(
+ {"name": "ANOTHER_PACKAGE"}
+ )
+ self.assertEqual(
+ self.picking5.move_line_ids.location_dest_id, self.packing_location
+ )
+ for move_line, package_dest in zip(
+ self.picking5.move_line_ids, self.free_package | self.another_package
+ ):
+ self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": package_dest.name,
+ "quantity": move_line.product_uom_qty,
+ },
+ )
+ # unload goods
+ response = self.service.dispatch(
+ "prepare_unload",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ },
+ )
+ # check response
+ self.assert_response_unload_all(
+ response, zone_location, picking_type, self.picking5.move_line_ids,
+ )
From 97071d6167a3c93563db1fbeb9b750e56fe05170 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Thu, 30 Jul 2020 11:56:02 +0200
Subject: [PATCH 303/940] zone picking: implement /unload_scan_pack
---
shopfloor/actions/message.py | 18 ++
shopfloor/services/zone_picking.py | 84 ++++++++-
shopfloor/tests/__init__.py | 1 +
.../tests/test_zone_picking_unload_single.py | 159 ++++++++++++++++++
4 files changed, 254 insertions(+), 8 deletions(-)
create mode 100644 shopfloor/tests/test_zone_picking_unload_single.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 0f72b69ecd..f8b02276a6 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -423,3 +423,21 @@ def lot_is_not_a_package(self, lot):
"message_type": "error",
"body": _("Lot {} is not a package.").format(lot.name),
}
+
+ def buffer_complete(self):
+ return {
+ "message_type": "success",
+ "body": _("Scanned destination packages processed."),
+ }
+
+ def picking_type_complete(self, picking_type):
+ return {
+ "message_type": "success",
+ "body": _("Picking type {} complete.").format(picking_type.name),
+ }
+
+ def barcode_no_match(self, barcode):
+ return {
+ "message_type": "warning",
+ "body": _("Barcode does not match with {}.").format(barcode),
+ }
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index d12ee07ad2..d911a2fbf8 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -157,6 +157,7 @@ def _response_for_confirm_unload_all(
def _response_for_unload_single(
self, zone_location, picking_type, move_line, message=None, popup=None
):
+ # TODO add picking completion_info
return self._response(
next_state="unload_single",
data=self._data_for_move_line(zone_location, picking_type, move_line),
@@ -260,20 +261,27 @@ def _sort_key_move_lines(order):
False,
)
- def _find_buffer_move_lines_domain(self, zone_location, picking_type):
- return [
+ def _find_buffer_move_lines_domain(
+ self, zone_location, picking_type, dest_package=None
+ ):
+ domain = [
("location_id", "child_of", zone_location.id),
("qty_done", ">", 0),
("state", "not in", ("cancel", "done")),
("result_package_id", "!=", False),
("shopfloor_user_id", "=", self.env.user.id),
]
+ if dest_package:
+ domain.append(("result_package_id", "=", dest_package.id))
+ return domain
- def _find_buffer_move_lines(self, zone_location, picking_type):
+ def _find_buffer_move_lines(self, zone_location, picking_type, dest_package=None):
"""Find lines that belongs to the operator's buffer and return them
grouped by destination package.
"""
- domain = self._find_buffer_move_lines_domain(zone_location, picking_type)
+ domain = self._find_buffer_move_lines_domain(
+ zone_location, picking_type, dest_package
+ )
return self.env["stock.move.line"].search(domain)
def _group_buffer_move_lines_by_package(self, move_lines):
@@ -944,6 +952,33 @@ def unload_split(self, zone_location_id, picking_type_id):
"""
return self._response()
+ def _unload_response(self, zone_location, picking_type, unload_single_message=None):
+ """Prepare the right response depending on the move lines to process."""
+ # if there are still move lines to process from the buffer
+ move_lines = self._find_buffer_move_lines(zone_location, picking_type)
+ if move_lines:
+ return self._response_for_unload_single(
+ zone_location,
+ picking_type,
+ first(move_lines),
+ message=unload_single_message,
+ )
+ # if there are still move lines to process from the picking type
+ # => buffer complete!
+ move_lines = self._find_location_move_lines(zone_location, picking_type)
+ if move_lines:
+ return self._response_for_select_line(
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.msg_store.buffer_complete(),
+ )
+ # no more move lines to process from the current picking type
+ # => picking type complete!
+ return self._response_for_start(
+ message=self.msg_store.picking_type_complete(picking_type)
+ )
+
def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcode):
"""Scan the destination package to check user moves the good one
@@ -954,11 +989,38 @@ def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcod
Transitions:
* unload_single: the scanned barcode does not match the package
* unload_set_destination: the scanned barcode matches the package
- * confirm_unload_set_destination: the scanned location is not in the
- expected one but is valid (in picking type's default destination)
* select_line: no remaining move lines in buffer
+ * start: no remaining move lines in picking type
"""
- return self._response()
+ zone_location = self.env["stock.location"].browse(zone_location_id)
+ if not zone_location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ picking_type = self.env["stock.picking.type"].browse(picking_type_id)
+ if not picking_type.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ package = self.env["stock.quant.package"].browse(package_id)
+ if not package.exists():
+ return self._unload_response(
+ zone_location,
+ picking_type,
+ unload_single_message=self.msg_store.record_not_found(),
+ )
+ search = self.actions_for("search")
+ scanned_package = search.package_from_scan(barcode)
+ # the scanned barcode matches the package
+ if scanned_package == package:
+ move_lines = self._find_buffer_move_lines(
+ zone_location, picking_type, dest_package=scanned_package
+ )
+ if move_lines:
+ return self._response_for_unload_set_destination(
+ zone_location, picking_type, first(move_lines)
+ )
+ return self._unload_response(
+ zone_location,
+ picking_type,
+ unload_single_message=self.msg_store.barcode_no_match(package.name),
+ )
def unload_set_destination(
self, zone_location_id, picking_type_id, package_id, barcode, confirmation=False
@@ -1085,6 +1147,7 @@ def unload_scan_pack(self):
"zone_location_id": {"coerce": to_int, "required": True, "type": "integer"},
"picking_type_id": {"coerce": to_int, "required": True, "type": "integer"},
"package_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "barcode": {"required": False, "nullable": True, "type": "string"},
}
def unload_set_destination(self):
@@ -1175,7 +1238,12 @@ def unload_split(self):
def unload_scan_pack(self):
return self._response_schema(
- next_states={"unload_single", "unload_set_destination", "select_line"}
+ next_states={
+ "unload_single",
+ "unload_set_destination",
+ "select_line",
+ "start",
+ }
)
def unload_set_destination(self):
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 2dea4bfe54..1c0b0f65e8 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -46,3 +46,4 @@
from . import test_zone_picking_zero_check
from . import test_zone_picking_stock_issue
from . import test_zone_picking_change_pack_lot
+from . import test_zone_picking_unload_single
diff --git a/shopfloor/tests/test_zone_picking_unload_single.py b/shopfloor/tests/test_zone_picking_unload_single.py
new file mode 100644
index 0000000000..ca9c7b37b0
--- /dev/null
+++ b/shopfloor/tests/test_zone_picking_unload_single.py
@@ -0,0 +1,159 @@
+from .test_zone_picking_base import ZonePickingCommonCase
+
+
+class ZonePickingUnloadSingleCase(ZonePickingCommonCase):
+ """Tests for endpoint used from unload_single
+
+ * /unload_scan_pack
+
+ """
+
+ def test_unload_scan_pack_wrong_parameters(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ package = move_line.package_id
+ response = self.service.dispatch(
+ "unload_scan_pack",
+ params={
+ "zone_location_id": 1234567890,
+ "picking_type_id": picking_type.id,
+ "package_id": package.id,
+ "barcode": "UNKNOWN",
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "unload_scan_pack",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": 1234567890,
+ "package_id": package.id,
+ "barcode": "UNKNOWN",
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ # wrong package ID, and there is still a move line to unload
+ # => get back on 'unload_single' screen
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "unload_scan_pack",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": 1234567890,
+ "barcode": "UNKNOWN",
+ },
+ )
+ self.assert_response_unload_single(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.record_not_found(),
+ )
+ # wrong package ID, and there is no more move line to unload from the buffer
+ # => get back on 'select_line' screen
+ move_line.write(
+ {"qty_done": 0, "shopfloor_user_id": False, "result_package_id": False}
+ )
+ response = self.service.dispatch(
+ "unload_scan_pack",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": 1234567890,
+ "barcode": "UNKNOWN",
+ },
+ )
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.buffer_complete(),
+ )
+ # wrong package ID, and there is no more move line to process in picking type
+ # => get back on 'start' screen
+ self.pickings.move_lines._do_unreserve()
+ response = self.service.dispatch(
+ "unload_scan_pack",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": 1234567890,
+ "barcode": "UNKNOWN",
+ },
+ )
+ self.assert_response_start(
+ response,
+ message=self.service.msg_store.picking_type_complete(picking_type),
+ )
+
+ def test_unload_scan_pack_barcode_match(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ # set the destination package
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "unload_scan_pack",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": move_line.result_package_id.id,
+ "barcode": self.free_package.name,
+ },
+ )
+ self.assert_response_unload_set_destination(
+ response, zone_location, picking_type, move_line,
+ )
+
+ def test_unload_scan_pack_barcode_not_match(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ self.wrong_package = self.env["stock.quant.package"].create(
+ {"name": "WRONG_PACKAGE"}
+ )
+ # set the destination package
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "unload_scan_pack",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": move_line.result_package_id.id,
+ "barcode": self.wrong_package.name,
+ },
+ )
+ self.assert_response_unload_single(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.barcode_no_match(self.free_package.name),
+ )
From 718ff8832af79401b4a040f8d0edc8afb01ad356 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Thu, 30 Jul 2020 15:14:20 +0200
Subject: [PATCH 304/940] shopfloor: move package storage_type info from
'data_detail' to 'data'
---
shopfloor/actions/data.py | 1 +
shopfloor/actions/data_detail.py | 1 -
shopfloor/services/schema.py | 1 +
shopfloor/services/schema_detail.py | 1 -
shopfloor/tests/test_actions_data.py | 20 ++++++++++++++++++++
shopfloor/tests/test_actions_data_detail.py | 4 ++++
6 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 10f04f8269..9d4d045d66 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -87,6 +87,7 @@ def _package_parser(self):
"id",
"name",
"pack_weight:weight",
+ ("package_storage_type_id:storage_type", ["id", "name"]),
]
@property
diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py
index 7e577ba9cc..4e25abbe42 100644
--- a/shopfloor/actions/data_detail.py
+++ b/shopfloor/actions/data_detail.py
@@ -71,7 +71,6 @@ def _package_detail_parser(self):
"reserved_move_line_ids:move_lines",
lambda record, fname: self.move_lines(record[fname]),
),
- ("package_storage_type_id:storage_type", ["id", "name"]),
]
def lot_detail(self, record, **kw):
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 59c085c033..62d4647f70 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -115,6 +115,7 @@ def package(self, with_packaging=False):
"name": {"type": "string", "nullable": False, "required": True},
"weight": {"required": True, "nullable": True, "type": "float"},
"move_line_count": {"required": False, "nullable": True, "type": "integer"},
+ "storage_type": self._schema_dict_of(self._simple_record()),
}
if with_packaging:
schema["packaging"] = self._schema_dict_of(self.packaging())
diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py
index 03fea1c23a..8f8e283c85 100644
--- a/shopfloor/services/schema_detail.py
+++ b/shopfloor/services/schema_detail.py
@@ -48,7 +48,6 @@ def package_detail(self):
schema.update(
{
"pickings": self._schema_list_of(self.picking()),
- "storage_type": self._schema_dict_of(self._simple_record()),
"move_lines": self._schema_list_of(self.move_line()),
}
)
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index fbd1942025..6ea9f5f984 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -17,6 +17,9 @@ def setUpClassVars(cls):
super().setUpClassVars()
cls.wh = cls.env.ref("stock.warehouse0")
cls.picking_type = cls.wh.out_type_id
+ cls.storage_type_pallet = cls.env.ref(
+ "stock_storage_type.package_storage_type_pallets"
+ )
@classmethod
def setUpClassBaseData(cls):
@@ -88,11 +91,20 @@ def _expected_packaging(self, record, **kw):
data.update(kw)
return data
+ def _expected_storage_type(self, record, **kw):
+ data = {
+ "id": record.id,
+ "name": record.name,
+ }
+ data.update(kw)
+ return data
+
def _expected_package(self, record, **kw):
data = {
"id": record.id,
"name": record.name,
"weight": record.pack_weight,
+ "storage_type": None,
}
data.update(kw)
return data
@@ -131,6 +143,7 @@ def test_data_lot(self):
def test_data_package(self):
package = self.move_a.move_line_ids.package_id
package.product_packaging_id = self.packaging.id
+ package.package_storage_type_id = self.storage_type_pallet
data = self.data.package(package, picking=self.picking, with_packaging=True)
self.assert_schema(self.schema.package(with_packaging=True), data)
expected = {
@@ -138,6 +151,9 @@ def test_data_package(self):
"name": package.name,
"move_line_count": 1,
"packaging": self._expected_packaging(package.product_packaging_id),
+ "storage_type": self._expected_storage_type(
+ package.package_storage_type_id
+ ),
"weight": 0.0,
}
self.assertDictEqual(data, expected)
@@ -222,6 +238,7 @@ def test_data_move_line_package(self):
"move_line_count": 1,
# TODO
"weight": 0.0,
+ "storage_type": None,
},
"package_dest": {
"id": result_package.id,
@@ -229,6 +246,7 @@ def test_data_move_line_package(self):
"move_line_count": 0,
# TODO
"weight": 0.0,
+ "storage_type": None,
},
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
@@ -276,6 +294,7 @@ def test_data_move_line_package_lot(self):
"move_line_count": 1,
# TODO
"weight": 0,
+ "storage_type": None,
},
"package_dest": {
"id": move_line.result_package_id.id,
@@ -283,6 +302,7 @@ def test_data_move_line_package_lot(self):
"move_line_count": 1,
# TODO
"weight": 0,
+ "storage_type": None,
},
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index 0fef6ab21f..c3315a5f2d 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -204,12 +204,14 @@ def test_data_move_line_package(self):
"name": move_line.package_id.name,
"move_line_count": 1,
"weight": 0.0,
+ "storage_type": None,
},
"package_dest": {
"id": result_package.id,
"name": result_package.name,
"move_line_count": 0,
"weight": 0.0,
+ "storage_type": None,
},
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
@@ -259,12 +261,14 @@ def test_data_move_line_package_lot(self):
"name": move_line.package_id.name,
"move_line_count": 1,
"weight": 0.0,
+ "storage_type": None,
},
"package_dest": {
"id": move_line.result_package_id.id,
"name": move_line.result_package_id.name,
"move_line_count": 1,
"weight": 0.0,
+ "storage_type": None,
},
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
From 8565566a419c2b2652e7865d44492f00498fe95f Mon Sep 17 00:00:00 2001
From: sebalix
Date: Thu, 30 Jul 2020 18:00:12 +0200
Subject: [PATCH 305/940] zone picking: implement /set_destination_all
---
shopfloor/actions/message.py | 6 +
shopfloor/services/zone_picking.py | 104 ++++++--
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_zone_picking_base.py | 19 +-
.../tests/test_zone_picking_unload_all.py | 242 ++++++++++++++++++
5 files changed, 347 insertions(+), 25 deletions(-)
create mode 100644 shopfloor/tests/test_zone_picking_unload_all.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index f8b02276a6..91bbd908d4 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -441,3 +441,9 @@ def barcode_no_match(self, barcode):
"message_type": "warning",
"body": _("Barcode does not match with {}.").format(barcode),
}
+
+ def lines_different_dest_location(self):
+ return {
+ "message_type": "error",
+ "body": _("Lines have different destination location."),
+ }
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index d911a2fbf8..793ebe4b6c 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -137,22 +137,18 @@ def _response_for_change_pack_lot(
)
def _response_for_unload_all(
- self, zone_location, picking_type, move_lines, message=None
- ):
- return self._response(
- next_state="unload_all",
- data=self._data_for_move_lines(zone_location, picking_type, move_lines),
- message=message,
- )
-
- def _response_for_confirm_unload_all(
- self, zone_location, picking_type, move_lines, message=None
+ self,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=None,
+ confirmation_required=False,
):
- return self._response(
- next_state="confirm_unload_all",
- data=self._data_for_move_lines(zone_location, picking_type, move_lines),
- message=message,
- )
+ if confirmation_required and not message:
+ message = self.msg_store.need_confirmation()
+ data = self._data_for_move_lines(zone_location, picking_type, move_lines)
+ data["confirmation_required"] = confirmation_required
+ return self._response(next_state="unload_all", data=data, message=message,)
def _response_for_unload_single(
self, zone_location, picking_type, move_line, message=None, popup=None
@@ -912,6 +908,20 @@ def prepare_unload(self, zone_location_id, picking_type_id):
move_lines = self._find_location_move_lines(zone_location, picking_type,)
return self._response_for_select_line(zone_location, picking_type, move_lines)
+ def _set_destination_all_response(
+ self, zone_location, picking_type, buffer_lines, message=None
+ ):
+ if buffer_lines:
+ return self._response_for_unload_all(
+ zone_location, picking_type, buffer_lines, message=message,
+ )
+ move_lines = self._find_location_move_lines(zone_location, picking_type)
+ if move_lines:
+ return self._response_for_select_line(
+ zone_location, picking_type, move_lines, message=message,
+ )
+ return self._response_for_start(message=message)
+
def set_destination_all(
self, zone_location_id, picking_type_id, barcode, confirmation=False
):
@@ -923,19 +933,65 @@ def set_destination_all(
a destination package, qty done > 0, and have the same destination
location.
- A scanned location outside of the source location of the operation type is
- invalid.
+ A scanned location outside of the destination location of the operation
+ type is invalid.
The move lines are then set to done, without backorders.
Transitions:
* unload_all: the scanned destination is invalid, user has to
scan another one
- * confirm_unload_all: the scanned location is not in the
+ * unload_all + confirmation: the scanned location is not in the
expected one but is valid (in picking type's default destination)
* select_line: no remaining move lines in buffer
"""
- return self._response()
+ zone_location = self.env["stock.location"].browse(zone_location_id)
+ if not zone_location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ picking_type = self.env["stock.picking.type"].browse(picking_type_id)
+ if not picking_type.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ search = self.actions_for("search")
+ location = search.location_from_scan(barcode)
+ message = None
+ buffer_lines = self._find_buffer_move_lines(zone_location, picking_type)
+ if location:
+ error = None
+ location_dest = buffer_lines.mapped("location_dest_id")
+ # check if move lines share the same destination
+ if len(location_dest) != 1:
+ error = self.msg_store.lines_different_dest_location()
+ # check if the scanned location is allowed
+ if not location.is_sublocation_of(picking_type.default_location_dest_id):
+ error = self.msg_store.location_not_allowed()
+ if error:
+ return self._set_destination_all_response(
+ zone_location, picking_type, buffer_lines, message=error
+ )
+ # check if the destination location is not the expected one
+ if location != picking_type.default_location_dest_id:
+ if not confirmation:
+ return self._response_for_unload_all(
+ zone_location,
+ picking_type,
+ buffer_lines,
+ message=self.msg_store.confirm_location_changed(
+ picking_type.default_location_dest_id, location
+ ),
+ confirmation_required=True,
+ )
+ # the scanned location is still valid, use it
+ buffer_lines.location_dest_id = location
+ # set lines to done + refresh buffer lines (should be empty)
+ moves = buffer_lines.mapped("move_id")
+ moves.with_context(_sf_no_backorder=True)._action_done()
+ message = self.msg_store.buffer_complete()
+ buffer_lines = self._find_buffer_move_lines(zone_location, picking_type)
+ else:
+ message = self.msg_store.no_location_found()
+ return self._set_destination_all_response(
+ zone_location, picking_type, buffer_lines, message=message,
+ )
def unload_split(self, zone_location_id, picking_type_id):
"""Indicates that now the buffer must be treated line per line
@@ -1181,7 +1237,6 @@ def _states(self):
"zero_check": self._schema_for_zero_check,
"change_pack_lot": self._schema_for_move_line,
"unload_all": self._schema_for_move_lines,
- "confirm_unload_all": self._schema_for_move_lines,
"unload_single": self._schema_for_move_line,
"unload_set_destination": self._schema_for_move_line,
"confirm_unload_set_destination": self._schema_for_move_line,
@@ -1227,9 +1282,7 @@ def prepare_unload(self):
)
def set_destination_all(self):
- return self._response_schema(
- next_states={"unload_all", "confirm_unload_all", "select_line"}
- )
+ return self._response_schema(next_states={"unload_all", "select_line"})
def unload_split(self):
return self._response_schema(
@@ -1284,6 +1337,11 @@ def _schema_for_move_lines(self):
"zone_location": self.schemas._schema_dict_of(self.schemas.location()),
"picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()),
"move_lines": self.schemas._schema_list_of(self.schemas.move_line()),
+ "confirmation_required": {
+ "type": "boolean",
+ "nullable": True,
+ "required": False,
+ },
}
return schema
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 1c0b0f65e8..e7a0ace758 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -47,3 +47,4 @@
from . import test_zone_picking_stock_issue
from . import test_zone_picking_change_pack_lot
from . import test_zone_picking_unload_single
+from . import test_zone_picking_unload_all
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 8031c291da..3d66f32bfa 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -350,7 +350,14 @@ def assert_response_unload_set_destination(
)
def _assert_response_unload_all(
- self, state, response, zone_location, picking_type, move_lines, message=None,
+ self,
+ state,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=None,
+ confirmation_required=False,
):
self.assert_response(
response,
@@ -359,12 +366,19 @@ def _assert_response_unload_all(
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
"move_lines": self.data.move_lines(move_lines),
+ "confirmation_required": confirmation_required,
},
message=message,
)
def assert_response_unload_all(
- self, response, zone_location, picking_type, move_lines, message=None,
+ self,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=None,
+ confirmation_required=False,
):
self._assert_response_unload_all(
"unload_all",
@@ -373,6 +387,7 @@ def assert_response_unload_all(
picking_type,
move_lines,
message=message,
+ confirmation_required=confirmation_required,
)
def _assert_response_unload_single(
diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py
new file mode 100644
index 0000000000..2cff790f7b
--- /dev/null
+++ b/shopfloor/tests/test_zone_picking_unload_all.py
@@ -0,0 +1,242 @@
+from .test_zone_picking_base import ZonePickingCommonCase
+
+
+class ZonePickingUnloadAllCase(ZonePickingCommonCase):
+ """Tests for endpoint used from unload_all
+
+ * /set_destination_all
+
+ """
+
+ def test_set_destination_all_wrong_parameters(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "zone_location_id": 1234567890,
+ "picking_type_id": picking_type.id,
+ "barcode": "UNKNOWN",
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": 1234567890,
+ "barcode": "UNKNOWN",
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_set_destination_all_different_destination(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line1 = self.picking5.move_line_ids[0]
+ move_line2 = self.picking5.move_line_ids[1]
+ another_package = self.env["stock.quant.package"].create(
+ {"name": "ANOTHER_PACKAGE"}
+ )
+ # change the destination location of one move line
+ move_line2.location_dest_id = self.zone_sublocation3
+ # set the destination package on lines
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line1,
+ move_line1.product_uom_qty,
+ self.free_package,
+ )
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line2,
+ move_line2.product_uom_qty,
+ another_package,
+ )
+ # set destination location for all lines in the buffer
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ # check response
+ buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
+ self.assert_response_unload_all(
+ response,
+ zone_location,
+ picking_type,
+ buffer_lines,
+ message=self.service.msg_store.lines_different_dest_location(),
+ )
+
+ def test_set_destination_all_confirm_destination(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line1 = self.picking5.move_line_ids[0]
+ move_line2 = self.picking5.move_line_ids[1]
+ another_package = self.env["stock.quant.package"].create(
+ {"name": "ANOTHER_PACKAGE"}
+ )
+ packing_sublocation = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Packing sublocation",
+ "location_id": self.packing_location.id,
+ "barcode": "PACKING_SUBLOCATION",
+ }
+ )
+ )
+ # set the destination package on lines
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line1,
+ move_line1.product_uom_qty,
+ self.free_package,
+ )
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line2,
+ move_line2.product_uom_qty,
+ another_package,
+ )
+ # set destination location for all lines in the buffer
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": packing_sublocation.barcode,
+ },
+ )
+ # check response
+ buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
+ self.assert_response_unload_all(
+ response,
+ zone_location,
+ picking_type,
+ buffer_lines,
+ message=self.service.msg_store.confirm_location_changed(
+ picking_type.default_location_dest_id, packing_sublocation,
+ ),
+ confirmation_required=True,
+ )
+
+ def test_set_destination_all_ok(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line1 = self.picking5.move_line_ids[0]
+ move_line2 = self.picking5.move_line_ids[1]
+ another_package = self.env["stock.quant.package"].create(
+ {"name": "ANOTHER_PACKAGE"}
+ )
+ # set the destination package on lines
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line1,
+ move_line1.product_uom_qty,
+ self.free_package,
+ )
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line2,
+ move_line2.product_uom_qty,
+ another_package,
+ )
+ # set destination location for all lines in the buffer
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ # check data
+ self.assertEqual(self.picking5.state, "done")
+ # buffer should be empty
+ buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
+ self.assertFalse(buffer_lines)
+ # check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.buffer_complete(),
+ )
+
+ def test_set_destination_all_location_not_allowed(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ # set the destination package on lines
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.customer_location.barcode,
+ },
+ )
+ # check response
+ buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
+ self.assert_response_unload_all(
+ response,
+ zone_location,
+ picking_type,
+ buffer_lines,
+ message=self.service.msg_store.location_not_allowed(),
+ )
+
+ def test_set_destination_all_location_not_found(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ # set the destination package on lines
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": "UNKNOWN",
+ },
+ )
+ # check response
+ buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
+ self.assert_response_unload_all(
+ response,
+ zone_location,
+ picking_type,
+ buffer_lines,
+ message=self.service.msg_store.no_location_found(),
+ )
From b7ea1c318a54c2dc41eec8c658962d1fb7d3a719 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Fri, 31 Jul 2020 09:25:52 +0200
Subject: [PATCH 306/940] zone picking: implement /unload_split
---
shopfloor/services/zone_picking.py | 26 ++++++-
.../tests/test_zone_picking_unload_all.py | 77 +++++++++++++++++++
2 files changed, 102 insertions(+), 1 deletion(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 793ebe4b6c..edb9feac10 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1006,7 +1006,31 @@ def unload_split(self, zone_location_id, picking_type_id):
* unload_set_destination: there is only one remaining line in the buffer
* select_line: no remaining move lines in buffer
"""
- return self._response()
+ zone_location = self.env["stock.location"].browse(zone_location_id)
+ if not zone_location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ picking_type = self.env["stock.picking.type"].browse(picking_type_id)
+ if not picking_type.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ buffer_lines = self._find_buffer_move_lines(zone_location, picking_type)
+ # more than one remaining move line in the buffer
+ if len(buffer_lines) > 1:
+ return self._response_for_unload_single(
+ zone_location, picking_type, first(buffer_lines),
+ )
+ # only one move line to process in the buffer
+ elif len(buffer_lines) == 1:
+ return self._response_for_unload_set_destination(
+ zone_location, picking_type, first(buffer_lines)
+ )
+ # no remaining move lines in buffer
+ move_lines = self._find_location_move_lines(zone_location, picking_type)
+ return self._response_for_select_line(
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.msg_store.buffer_complete(),
+ )
def _unload_response(self, zone_location, picking_type, unload_single_message=None):
"""Prepare the right response depending on the move lines to process."""
diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py
index 2cff790f7b..3666e15141 100644
--- a/shopfloor/tests/test_zone_picking_unload_all.py
+++ b/shopfloor/tests/test_zone_picking_unload_all.py
@@ -5,6 +5,7 @@ class ZonePickingUnloadAllCase(ZonePickingCommonCase):
"""Tests for endpoint used from unload_all
* /set_destination_all
+ * /unload_split
"""
@@ -240,3 +241,79 @@ def test_set_destination_all_location_not_found(self):
buffer_lines,
message=self.service.msg_store.no_location_found(),
)
+
+ def test_unload_split_buffer_empty(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "unload_split",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ },
+ )
+ # check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.buffer_complete(),
+ )
+
+ def test_unload_split_buffer_one_line(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ # put one line in the buffer
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "unload_split",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ },
+ )
+ # check response
+ buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
+ self.assert_response_unload_set_destination(
+ response, zone_location, picking_type, buffer_lines,
+ )
+
+ def test_unload_split_buffer_multi_lines(self):
+ zone_location = self.zone_location
+ picking_type = self.picking5.picking_type_id
+ move_line = self.picking5.move_line_ids
+ # put several lines in the buffer
+ self.another_package = self.env["stock.quant.package"].create(
+ {"name": "ANOTHER_PACKAGE"}
+ )
+ for move_line, package_dest in zip(
+ self.picking5.move_line_ids, self.free_package | self.another_package
+ ):
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ package_dest,
+ )
+ response = self.service.dispatch(
+ "unload_split",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ },
+ )
+ # check response
+ buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
+ self.assert_response_unload_single(
+ response, zone_location, picking_type, buffer_lines[0],
+ )
From ccef757e6b281ac6c830f6491855bf3b5c861a26 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Fri, 31 Jul 2020 14:06:35 +0200
Subject: [PATCH 307/940] zone picking: implement /unload_set_destination
---
shopfloor/services/zone_picking.py | 113 ++++++--
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_zone_picking_base.py | 19 +-
...est_zone_picking_unload_set_destination.py | 246 ++++++++++++++++++
4 files changed, 351 insertions(+), 28 deletions(-)
create mode 100644 shopfloor/tests/test_zone_picking_unload_set_destination.py
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index edb9feac10..565b53e215 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -162,20 +162,19 @@ def _response_for_unload_single(
)
def _response_for_unload_set_destination(
- self, zone_location, picking_type, move_line, message=None
- ):
- return self._response(
- next_state="unload_set_destination",
- data=self._data_for_move_line(zone_location, picking_type, move_line),
- message=message,
- )
-
- def _response_for_confirm_unload_set_destination(
- self, zone_location, picking_type, move_line
+ self,
+ zone_location,
+ picking_type,
+ move_line,
+ message=None,
+ confirmation_required=False,
):
+ if confirmation_required and not message:
+ message = self.msg_store.need_confirmation()
+ data = self._data_for_move_line(zone_location, picking_type, move_line)
+ data["confirmation_required"] = confirmation_required
return self._response(
- next_state="confirm_unload_set_destination",
- data=self._data_for_move_line(zone_location, picking_type, move_line),
+ next_state="unload_set_destination", data=data, message=message,
)
def _data_for_select_picking_type(self, zone_location, picking_types):
@@ -1102,6 +1101,11 @@ def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcod
unload_single_message=self.msg_store.barcode_no_match(package.name),
)
+ def _unload_set_destination_lock_lines(self, lines):
+ """Lock move lines"""
+ sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE" % lines._table
+ self.env.cr.execute(sql, (tuple(lines.ids),), log_exceptions=False)
+
def unload_set_destination(
self, zone_location_id, picking_type_id, package_id, barcode, confirmation=False
):
@@ -1119,15 +1123,78 @@ def unload_set_destination(
* unload_single: buffer still contains move lines, unload the next package
* unload_set_destination: the scanned location is invalid, user has to
scan another one
- * confirm_unload_set_destination: the scanned location is not in the
- expected one but is valid (in picking type's default destination)
+ * unload_set_destination+confirmation_required: the scanned location is not
+ in the expected one but is valid (in picking type's default destination)
* select_line: no remaining move lines in buffer
+ * start: no remaining move lines to process in the picking type
"""
- # TODO on _action_done, use ``_sf_no_backorder`` in the
- # context to disable backorders (see override in stock_picking.py).
- # TODO return a popup with completion info alongside the response,
- # see in cluster_picking.py how it's done
- return self._response()
+ zone_location = self.env["stock.location"].browse(zone_location_id)
+ if not zone_location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ picking_type = self.env["stock.picking.type"].browse(picking_type_id)
+ if not picking_type.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ package = self.env["stock.quant.package"].browse(package_id)
+ if not package.exists():
+ move_lines = self._find_location_move_lines(zone_location, picking_type)
+ return self._response_for_select_line(
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.msg_store.record_not_found(),
+ )
+ buffer_lines = self._find_buffer_move_lines(
+ zone_location, picking_type, dest_package=package
+ )
+ search = self.actions_for("search")
+ location = search.location_from_scan(barcode)
+ if location:
+ if not location.is_sublocation_of(picking_type.default_location_dest_id):
+ return self._response_for_unload_set_destination(
+ zone_location,
+ picking_type,
+ first(buffer_lines),
+ message=self.msg_store.dest_location_not_allowed(),
+ )
+ if location != picking_type.default_location_dest_id:
+ if not confirmation:
+ return self._response_for_unload_set_destination(
+ zone_location,
+ picking_type,
+ first(buffer_lines),
+ message=self.msg_store.confirm_location_changed(
+ picking_type.default_location_dest_id, location
+ ),
+ confirmation_required=True,
+ )
+ # the scanned location is valid, use it
+ self._unload_set_destination_lock_lines(buffer_lines)
+ buffer_lines.location_dest_id = location
+ # set lines to done + refresh buffer lines (should be empty)
+ moves = buffer_lines.mapped("move_id")
+ moves.with_context(_sf_no_backorder=True)._action_done()
+ buffer_lines = self._find_buffer_move_lines(zone_location, picking_type)
+ if buffer_lines:
+ return self._response_for_unload_single(
+ zone_location, picking_type, first(buffer_lines),
+ )
+ move_lines = self._find_location_move_lines(zone_location, picking_type)
+ if move_lines:
+ return self._response_for_select_line(
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.msg_store.buffer_complete(),
+ )
+ return self._response_for_start(
+ message=self.msg_store.picking_type_complete(picking_type)
+ )
+ return self._response_for_unload_set_destination(
+ zone_location,
+ picking_type,
+ first(buffer_lines),
+ message=self.msg_store.no_location_found(),
+ )
class ShopfloorZonePickingValidator(Component):
@@ -1263,7 +1330,6 @@ def _states(self):
"unload_all": self._schema_for_move_lines,
"unload_single": self._schema_for_move_line,
"unload_set_destination": self._schema_for_move_line,
- "confirm_unload_set_destination": self._schema_for_move_line,
}
def scan_location(self):
@@ -1325,12 +1391,7 @@ def unload_scan_pack(self):
def unload_set_destination(self):
return self._response_schema(
- next_states={
- "unload_single",
- "unload_set_destination",
- "confirm_unload_set_destination",
- "select_line",
- }
+ next_states={"unload_single", "unload_set_destination", "select_line"}
)
@property
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index e7a0ace758..8f64051db9 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -48,3 +48,4 @@
from . import test_zone_picking_change_pack_lot
from . import test_zone_picking_unload_single
from . import test_zone_picking_unload_all
+from . import test_zone_picking_unload_set_destination
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 3d66f32bfa..1b9d28a6e0 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -324,7 +324,14 @@ def assert_response_change_pack_lot(
)
def _assert_response_unload_set_destination(
- self, state, response, zone_location, picking_type, move_line, message=None,
+ self,
+ state,
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=None,
+ confirmation_required=False,
):
self.assert_response(
response,
@@ -333,12 +340,19 @@ def _assert_response_unload_set_destination(
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
"move_line": self.data.move_line(move_line),
+ "confirmation_required": confirmation_required,
},
message=message,
)
def assert_response_unload_set_destination(
- self, response, zone_location, picking_type, move_line, message=None,
+ self,
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=None,
+ confirmation_required=False,
):
self._assert_response_unload_set_destination(
"unload_set_destination",
@@ -347,6 +361,7 @@ def assert_response_unload_set_destination(
picking_type,
move_line,
message=message,
+ confirmation_required=confirmation_required,
)
def _assert_response_unload_all(
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
new file mode 100644
index 0000000000..985b6260ee
--- /dev/null
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -0,0 +1,246 @@
+from .test_zone_picking_base import ZonePickingCommonCase
+
+
+class ZonePickingUnloadSetDestinationCase(ZonePickingCommonCase):
+ """Tests for endpoint used from unload_set_destination
+
+ * /unload_set_destination
+
+ """
+
+ def test_unload_set_destination_wrong_parameters(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ package = move_line.package_id
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": 1234567890,
+ "picking_type_id": picking_type.id,
+ "package_id": package.id,
+ "barcode": "BARCODE",
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": 1234567890,
+ "package_id": package.id,
+ "barcode": "BARCODE",
+ },
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": 1234567890,
+ "barcode": "BARCODE",
+ },
+ )
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_unload_set_destination_no_location_found(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ # set the destination package
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": self.free_package.id,
+ "barcode": "UNKNOWN",
+ },
+ )
+ self.assert_response_unload_set_destination(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.no_location_found(),
+ )
+
+ def test_unload_set_destination_location_not_allowed(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ # set the destination package
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": self.free_package.id,
+ "barcode": self.customer_location.barcode,
+ },
+ )
+ self.assert_response_unload_set_destination(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.dest_location_not_allowed(),
+ )
+
+ def test_unload_set_destination_confirm_location(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ packing_sublocation = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Packing sublocation",
+ "location_id": self.packing_location.id,
+ "barcode": "PACKING_SUBLOCATION",
+ }
+ )
+ )
+ # set the destination package
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": self.free_package.id,
+ "barcode": packing_sublocation.barcode,
+ },
+ )
+ self.assert_response_unload_set_destination(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.confirm_location_changed(
+ picking_type.default_location_dest_id, packing_sublocation
+ ),
+ confirmation_required=True,
+ )
+
+ def test_unload_set_destination_ok_buffer_empty(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ packing_sublocation = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Packing sublocation",
+ "location_id": self.packing_location.id,
+ "barcode": "PACKING_SUBLOCATION",
+ }
+ )
+ )
+ # set the destination package
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": self.free_package.id,
+ "barcode": packing_sublocation.barcode,
+ "confirmation": True,
+ },
+ )
+ # check data
+ self.assertEqual(move_line.location_dest_id, packing_sublocation)
+ self.assertEqual(move_line.move_id.state, "done")
+ # check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.buffer_complete(),
+ )
+
+ def test_unload_set_destination_ok_buffer_not_empty(self):
+ zone_location = self.zone_location
+ picking_type = self.picking5.picking_type_id
+ # put several lines in the buffer
+ self.another_package = self.env["stock.quant.package"].create(
+ {"name": "ANOTHER_PACKAGE"}
+ )
+ for move_line, package_dest in zip(
+ self.picking5.move_line_ids, self.free_package | self.another_package
+ ):
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ package_dest,
+ )
+ # process 1/2 buffer line
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": self.free_package.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ # check data
+ move_line = self.picking5.move_line_ids.filtered(
+ lambda l: l.result_package_id == self.free_package
+ )
+ self.assertEqual(move_line.location_dest_id, self.packing_location)
+ self.assertEqual(move_line.move_id.state, "done")
+ # check response
+ buffer_line = self.service._find_buffer_move_lines(zone_location, picking_type)
+ self.assert_response_unload_single(
+ response,
+ zone_location,
+ picking_type,
+ buffer_line,
+ # TODO check completion_info
+ )
From 127a0b5193d0410bd95df2494de919a30d5b5cb7 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Fri, 31 Jul 2020 14:44:22 +0200
Subject: [PATCH 308/940] zone picking: send completion info popup on
'unload_single' screen
---
shopfloor/services/zone_picking.py | 6 ++++--
shopfloor/tests/test_zone_picking_base.py | 13 +++++++++++--
shopfloor/tests/test_zone_picking_unload_all.py | 8 +++++++-
.../test_zone_picking_unload_set_destination.py | 4 +++-
shopfloor/tests/test_zone_picking_unload_single.py | 3 +++
5 files changed, 28 insertions(+), 6 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 565b53e215..1d4e814546 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -153,12 +153,14 @@ def _response_for_unload_all(
def _response_for_unload_single(
self, zone_location, picking_type, move_line, message=None, popup=None
):
- # TODO add picking completion_info
+ buffer_lines = self._find_buffer_move_lines(zone_location, picking_type)
+ completion_info = self.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(buffer_lines)
return self._response(
next_state="unload_single",
data=self._data_for_move_line(zone_location, picking_type, move_line),
message=message,
- popup=popup,
+ popup=popup or completion_info_popup,
)
def _response_for_unload_set_destination(
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 1b9d28a6e0..1f66fde167 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -406,7 +406,14 @@ def assert_response_unload_all(
)
def _assert_response_unload_single(
- self, state, response, zone_location, picking_type, move_line, message=None,
+ self,
+ state,
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=None,
+ popup=None,
):
self.assert_response(
response,
@@ -417,10 +424,11 @@ def _assert_response_unload_single(
"move_line": self.data.move_line(move_line),
},
message=message,
+ popup=popup,
)
def assert_response_unload_single(
- self, response, zone_location, picking_type, move_line, message=None,
+ self, response, zone_location, picking_type, move_line, message=None, popup=None
):
self._assert_response_unload_single(
"unload_single",
@@ -429,4 +437,5 @@ def assert_response_unload_single(
picking_type,
move_line,
message=message,
+ popup=popup,
)
diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py
index 3666e15141..86ebbc4ed9 100644
--- a/shopfloor/tests/test_zone_picking_unload_all.py
+++ b/shopfloor/tests/test_zone_picking_unload_all.py
@@ -314,6 +314,12 @@ def test_unload_split_buffer_multi_lines(self):
)
# check response
buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
+ completion_info = self.service.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(buffer_lines)
self.assert_response_unload_single(
- response, zone_location, picking_type, buffer_lines[0],
+ response,
+ zone_location,
+ picking_type,
+ buffer_lines[0],
+ popup=completion_info_popup,
)
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
index 985b6260ee..38492df979 100644
--- a/shopfloor/tests/test_zone_picking_unload_set_destination.py
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -237,10 +237,12 @@ def test_unload_set_destination_ok_buffer_not_empty(self):
self.assertEqual(move_line.move_id.state, "done")
# check response
buffer_line = self.service._find_buffer_move_lines(zone_location, picking_type)
+ completion_info = self.service.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(buffer_line)
self.assert_response_unload_single(
response,
zone_location,
picking_type,
buffer_line,
- # TODO check completion_info
+ popup=completion_info_popup,
)
diff --git a/shopfloor/tests/test_zone_picking_unload_single.py b/shopfloor/tests/test_zone_picking_unload_single.py
index ca9c7b37b0..e6b67d7889 100644
--- a/shopfloor/tests/test_zone_picking_unload_single.py
+++ b/shopfloor/tests/test_zone_picking_unload_single.py
@@ -55,12 +55,15 @@ def test_unload_scan_pack_wrong_parameters(self):
"barcode": "UNKNOWN",
},
)
+ completion_info = self.service.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(move_line)
self.assert_response_unload_single(
response,
zone_location,
picking_type,
move_line,
message=self.service.msg_store.record_not_found(),
+ popup=completion_info_popup,
)
# wrong package ID, and there is no more move line to unload from the buffer
# => get back on 'select_line' screen
From 7275f81173e524cb9c340f0258b89450fa94f0f5 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 31 Jul 2020 17:33:59 +0200
Subject: [PATCH 309/940] bknd: zone picking misc fixes
---
shopfloor/actions/data.py | 9 +++-
shopfloor/services/schema.py | 15 ++++++-
shopfloor/services/schema_detail.py | 5 ---
shopfloor/services/zone_picking.py | 17 +++++---
shopfloor/tests/test_actions_data.py | 24 +++++++++++
shopfloor/tests/test_actions_data_detail.py | 6 ++-
shopfloor/tests/test_checkout_list_package.py | 1 +
shopfloor/tests/test_zone_picking_base.py | 12 +++---
.../test_zone_picking_set_line_destination.py | 42 +++++++++++++++----
9 files changed, 102 insertions(+), 29 deletions(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 9d4d045d66..dc156516f7 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -59,6 +59,7 @@ def _picking_parser(self):
("partner_id:partner", self._partner_parser),
"move_line_count",
"total_weight:weight",
+ "scheduled_date",
]
def package(self, record, picking=None, with_packaging=False, **kw):
@@ -116,9 +117,12 @@ def lots(self, record, **kw):
def _lot_parser(self):
return self._simple_record_parser() + ["ref"]
- def move_line(self, record, **kw):
+ def move_line(self, record, with_picking=False, **kw):
record = record.with_context(location=record.location_id.id)
- data = self._jsonify(record, self._move_line_parser)
+ parser = self._move_line_parser
+ if with_picking:
+ parser += [("picking_id:picking", self._picking_parser)]
+ data = self._jsonify(record, parser)
if data:
data.update(
{
@@ -147,6 +151,7 @@ def _move_line_parser(self):
("lot_id:lot", self._lot_parser),
("location_id:location_src", self._location_parser),
("location_dest_id:location_dest", self._location_parser),
+ ("move_id:priority", lambda rec, fname: rec.move_id.priority or "",),
]
def package_level(self, record, **kw):
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 62d4647f70..cf0689f480 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -69,10 +69,11 @@ def picking(self):
"name": {"type": "string", "nullable": False, "required": True},
},
},
+ "scheduled_date": {"type": "string", "nullable": False, "required": True},
}
- def move_line(self, with_packaging=False):
- return {
+ def move_line(self, with_packaging=False, with_picking=False):
+ schema = {
"id": {"type": "integer", "required": True},
"qty_done": {"type": "float", "required": True},
"quantity": {"type": "float", "required": True},
@@ -91,6 +92,16 @@ def move_line(self, with_packaging=False):
),
"location_src": self._schema_dict_of(self.location()),
"location_dest": self._schema_dict_of(self.location()),
+ "priority": {"type": "string", "nullable": True, "required": False},
+ }
+ if with_picking:
+ schema["picking"] = self._schema_dict_of(self.picking())
+ return schema
+
+ def move(self):
+ return {
+ "id": {"required": True, "type": "integer"},
+ "priority": {"type": "string", "required": False, "nullable": True},
}
def product(self):
diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py
index 8f8e283c85..077df97344 100644
--- a/shopfloor/services/schema_detail.py
+++ b/shopfloor/services/schema_detail.py
@@ -31,11 +31,6 @@ def picking_detail(self):
schema.update(
{
"priority": {"type": "string", "nullable": True, "required": False},
- "scheduled_date": {
- "type": "string",
- "nullable": False,
- "required": True,
- },
"operation_type": self._schema_dict_of(self._simple_record()),
"carrier": self._schema_dict_of(self._simple_record()),
"move_lines": self._schema_list_of(self.move_line()),
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 1d4e814546..a2eadd1a92 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -190,14 +190,14 @@ def _data_for_move_line(self, zone_location, picking_type, move_line):
return {
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
- "move_line": self.data.move_line(move_line),
+ "move_line": self.data.move_line(move_line, with_picking=True),
}
def _data_for_move_lines(self, zone_location, picking_type, move_lines):
return {
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
- "move_lines": self.data.move_lines(move_lines),
+ "move_lines": self.data.move_lines(move_lines, with_picking=True),
}
def _data_for_location(self, zone_location, picking_type, location):
@@ -666,7 +666,10 @@ def set_destination(
if response:
return response
# Process the next line
- return self.list_move_lines(zone_location.id, picking_type.id)
+ response = self.list_move_lines(zone_location.id, picking_type.id)
+ return self._response(
+ base_response=response, message=self.msg_store.confirm_pack_moved(),
+ )
def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero):
"""Confirm or not if the source location of a move has zero qty
@@ -1409,7 +1412,9 @@ def _schema_for_move_line(self):
schema = {
"zone_location": self.schemas._schema_dict_of(self.schemas.location()),
"picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()),
- "move_line": self.schemas._schema_dict_of(self.schemas.move_line()),
+ "move_line": self.schemas._schema_dict_of(
+ self.schemas.move_line(with_picking=True)
+ ),
"confirmation_required": {
"type": "boolean",
"nullable": True,
@@ -1423,7 +1428,9 @@ def _schema_for_move_lines(self):
schema = {
"zone_location": self.schemas._schema_dict_of(self.schemas.location()),
"picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()),
- "move_lines": self.schemas._schema_list_of(self.schemas.move_line()),
+ "move_lines": self.schemas._schema_list_of(
+ self.schemas.move_line(with_picking=True)
+ ),
"confirmation_required": {
"type": "boolean",
"nullable": True,
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 6ea9f5f984..7249d1874e 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -191,6 +191,7 @@ def test_data_picking(self):
"weight": 110.0,
"partner": {"id": self.customer.id, "name": self.customer.name},
}
+ self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-08-03")
self.assertDictEqual(data, expected)
def test_data_product(self):
@@ -250,6 +251,7 @@ def test_data_move_line_package(self):
},
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
+ "priority": "1",
}
self.assertDictEqual(data, expected)
@@ -271,6 +273,7 @@ def test_data_move_line_lot(self):
"package_dest": None,
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
+ "priority": "1",
}
self.assertDictEqual(data, expected)
@@ -306,6 +309,7 @@ def test_data_move_line_package_lot(self):
},
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
+ "priority": "1",
}
self.assertDictEqual(data, expected)
@@ -323,6 +327,26 @@ def test_data_move_line_raw(self):
"package_dest": None,
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
+ "priority": "1",
+ }
+ self.assertDictEqual(data, expected)
+
+ def test_data_move_line_with_picking(self):
+ move_line = self.move_d.move_line_ids
+ data = self.data.move_line(move_line, with_picking=True)
+ self.assert_schema(self.schema.move_line(with_picking=True), data)
+ expected = {
+ "id": move_line.id,
+ "qty_done": 0.0,
+ "quantity": move_line.product_uom_qty,
+ "product": self._expected_product(self.product_d),
+ "lot": None,
+ "package_src": None,
+ "package_dest": None,
+ "location_src": self._expected_location(move_line.location_id),
+ "location_dest": self._expected_location(move_line.location_dest_id),
+ "picking": self.data.picking(move_line.picking_id),
+ "priority": "1",
}
self.assertDictEqual(data, expected)
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index c3315a5f2d..54f280bbc1 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -181,7 +181,6 @@ def test_data_picking(self):
"move_lines": self.data_detail.move_lines(picking.move_line_ids),
}
self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13")
- self.maxDiff = None
self.assertDictEqual(data, expected)
def test_data_move_line_package(self):
@@ -215,6 +214,7 @@ def test_data_move_line_package(self):
},
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
+ "priority": "1",
}
self.assertDictEqual(data, expected)
@@ -237,11 +237,11 @@ def test_data_move_line_lot(self):
"package_dest": None,
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
+ "priority": "1",
}
self.assertDictEqual(data, expected)
def test_data_move_line_package_lot(self):
- self.maxDiff = None
move_line = self.move_c.move_line_ids
data = self.data_detail.move_line(move_line)
self.assert_schema(self.schema_detail.move_line(), data)
@@ -272,6 +272,7 @@ def test_data_move_line_package_lot(self):
},
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
+ "priority": "1",
}
self.assertDictEqual(data, expected)
@@ -290,6 +291,7 @@ def test_data_move_line_raw(self):
"package_dest": None,
"location_src": self._expected_location(move_line.location_id),
"location_dest": self._expected_location(move_line.location_dest_id),
+ "priority": "1",
}
self.assertDictEqual(data, expected)
diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py
index e5d21d8fa3..fe81fbb366 100644
--- a/shopfloor/tests/test_checkout_list_package.py
+++ b/shopfloor/tests/test_checkout_list_package.py
@@ -18,6 +18,7 @@ def _assert_response_select_dest_package(
"weight": 110.0,
"move_line_count": len(picking.move_line_ids),
"partner": {"id": self.customer.id, "name": self.customer.name},
+ "scheduled_date": picking.scheduled_date.isoformat() + "+00:00",
},
"packages": [
self._package_data(package, picking) for package in packages
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 1f66fde167..ddd2897411 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -205,7 +205,7 @@ def _assert_response_select_line(
data={
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
- "move_lines": self.data.move_lines(move_lines),
+ "move_lines": self.data.move_lines(move_lines, with_picking=True),
},
message=message,
popup=popup,
@@ -246,7 +246,7 @@ def _assert_response_set_line_destination(
data={
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
- "move_line": self.data.move_line(move_line),
+ "move_line": self.data.move_line(move_line, with_picking=True),
"confirmation_required": confirmation_required,
},
message=message,
@@ -306,7 +306,7 @@ def _assert_response_change_pack_lot(
data={
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
- "move_line": self.data.move_line(move_line),
+ "move_line": self.data.move_line(move_line, with_picking=True),
},
message=message,
)
@@ -339,7 +339,7 @@ def _assert_response_unload_set_destination(
data={
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
- "move_line": self.data.move_line(move_line),
+ "move_line": self.data.move_line(move_line, with_picking=True),
"confirmation_required": confirmation_required,
},
message=message,
@@ -380,7 +380,7 @@ def _assert_response_unload_all(
data={
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
- "move_lines": self.data.move_lines(move_lines),
+ "move_lines": self.data.move_lines(move_lines, with_picking=True),
"confirmation_required": confirmation_required,
},
message=message,
@@ -421,7 +421,7 @@ def _assert_response_unload_single(
data={
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
- "move_line": self.data.move_line(move_line),
+ "move_line": self.data.move_line(move_line, with_picking=True),
},
message=message,
popup=popup,
diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py
index 88568e70fd..bf2c13f50f 100644
--- a/shopfloor/tests/test_zone_picking_set_line_destination.py
+++ b/shopfloor/tests/test_zone_picking_set_line_destination.py
@@ -122,7 +122,11 @@ def test_set_destination_location_confirm(self):
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.confirm_pack_moved(),
)
def test_set_destination_location_no_other_move_line_full_qty(self):
@@ -165,7 +169,11 @@ def test_set_destination_location_no_other_move_line_full_qty(self):
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.confirm_pack_moved(),
)
def test_set_destination_location_no_other_move_line_partial_qty(self):
@@ -218,7 +226,11 @@ def test_set_destination_location_no_other_move_line_partial_qty(self):
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.confirm_pack_moved(),
)
def test_set_destination_location_several_move_line_full_qty(self):
@@ -275,7 +287,11 @@ def test_set_destination_location_several_move_line_full_qty(self):
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.confirm_pack_moved(),
)
def test_set_destination_location_several_move_line_partial_qty(self):
@@ -337,7 +353,11 @@ def test_set_destination_location_several_move_line_partial_qty(self):
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.confirm_pack_moved(),
)
def test_set_destination_location_zero_check(self):
@@ -418,7 +438,11 @@ def test_set_destination_package_full_qty(self):
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.confirm_pack_moved(),
)
def test_set_destination_package_partial_qty(self):
@@ -485,7 +509,11 @@ def test_set_destination_package_partial_qty(self):
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.confirm_pack_moved(),
)
def test_set_destination_package_zero_check(self):
From 67c8eb91ebc12e4867726f9d01d6e2644ad25f89 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 10 Aug 2020 11:20:43 +0200
Subject: [PATCH 310/940] checkout: fix packaging field to use
---
shopfloor/actions/data.py | 2 +-
shopfloor/services/checkout.py | 4 ++--
shopfloor/tests/test_actions_data.py | 6 +++---
shopfloor/tests/test_actions_data_detail.py | 6 +++---
shopfloor/tests/test_checkout_change_packaging.py | 4 ++--
shopfloor/tests/test_checkout_scan_package_action.py | 2 +-
6 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index dc156516f7..f2d7d1bd76 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -94,7 +94,7 @@ def _package_parser(self):
@property
def _package_packaging_parser(self):
return [
- ("product_packaging_id:packaging", self._packaging_parser),
+ ("packaging_id:packaging", self._packaging_parser),
]
def packaging(self, record, **kw):
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 3f08861d8a..b24e9f7b14 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -625,7 +625,7 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package):
def _prepare_vals_package_from_packaging(self, packaging):
return {
- "product_packaging_id": packaging.id,
+ "packaging_id": packaging.id,
"lngth": packaging.lngth,
"width": packaging.width,
"height": packaging.height,
@@ -895,7 +895,7 @@ def set_packaging(self, picking_id, package_id, packaging_id):
return self._response_for_summary(
picking, message=self.msg_store.record_not_found()
)
- package.product_packaging_id = packaging
+ package.packaging_id = packaging
return self._response_for_summary(
picking,
message={
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 7249d1874e..877883a99a 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -142,7 +142,7 @@ def test_data_lot(self):
def test_data_package(self):
package = self.move_a.move_line_ids.package_id
- package.product_packaging_id = self.packaging.id
+ package.packaging_id = self.packaging.id
package.package_storage_type_id = self.storage_type_pallet
data = self.data.package(package, picking=self.picking, with_packaging=True)
self.assert_schema(self.schema.package(with_packaging=True), data)
@@ -150,7 +150,7 @@ def test_data_package(self):
"id": package.id,
"name": package.name,
"move_line_count": 1,
- "packaging": self._expected_packaging(package.product_packaging_id),
+ "packaging": self._expected_packaging(package.packaging_id),
"storage_type": self._expected_storage_type(
package.package_storage_type_id
),
@@ -222,7 +222,7 @@ def test_data_product(self):
def test_data_move_line_package(self):
move_line = self.move_a.move_line_ids
result_package = self.env["stock.quant.package"].create(
- {"product_packaging_id": self.packaging.id}
+ {"packaging_id": self.packaging.id}
)
move_line.write({"qty_done": 3.0, "result_package_id": result_package.id})
data = self.data.move_line(move_line)
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index 54f280bbc1..22821ee0a5 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -125,7 +125,7 @@ def test_data_lot(self):
def test_data_package(self):
package = self.move_a.move_line_ids.package_id
- package.product_packaging_id = self.packaging.id
+ package.packaging_id = self.packaging.id
package.package_storage_type_id = self.storage_type_pallet
# package.invalidate_cache()
data = self.data_detail.package_detail(package, picking=self.picking)
@@ -139,7 +139,7 @@ def test_data_package(self):
"id": package.id,
"name": package.name,
"move_line_count": 1,
- "packaging": self.data_detail.packaging(package.product_packaging_id),
+ "packaging": self.data_detail.packaging(package.packaging_id),
"weight": 0,
"pickings": self.data_detail.pickings(pickings),
"move_lines": self.data_detail.move_lines(lines),
@@ -186,7 +186,7 @@ def test_data_picking(self):
def test_data_move_line_package(self):
move_line = self.move_a.move_line_ids
result_package = self.env["stock.quant.package"].create(
- {"product_packaging_id": self.packaging.id}
+ {"packaging_id": self.packaging.id}
)
move_line.write({"qty_done": 3.0, "result_package_id": result_package.id})
data = self.data_detail.move_line(move_line)
diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py
index 0fc93b7674..5f79c6ec40 100644
--- a/shopfloor/tests/test_checkout_change_packaging.py
+++ b/shopfloor/tests/test_checkout_change_packaging.py
@@ -51,7 +51,7 @@ def setUpClassBaseData(cls):
cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True)
cls.picking.action_assign()
cls.package = cls.picking.move_line_ids.result_package_id
- cls.package.product_packaging_id = cls.packaging_pallet
+ cls.package.packaging_id = cls.packaging_pallet
def test_list_packaging_ok(self):
response = self.service.dispatch(
@@ -101,7 +101,7 @@ def test_set_packaging_ok(self):
},
)
self.assertRecordValues(
- self.package, [{"product_packaging_id": self.packaging_inner_box.id}]
+ self.package, [{"packaging_id": self.packaging_inner_box.id}]
)
self.assert_response(
response,
diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py
index c708ec47ea..26ab339947 100644
--- a/shopfloor/tests/test_checkout_scan_package_action.py
+++ b/shopfloor/tests/test_checkout_scan_package_action.py
@@ -325,7 +325,7 @@ def test_scan_package_action_scan_packaging_ok(self):
new_package,
[
{
- "product_packaging_id": packaging.id,
+ "packaging_id": packaging.id,
"lngth": packaging.lngth,
"width": packaging.width,
"height": packaging.height,
From e263cfbc2d70f40cb8bb118a59de995d9e8921a6 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 10 Aug 2020 12:44:24 +0200
Subject: [PATCH 311/940] zone picking: fix test about picking scheduled_date
---
shopfloor/tests/test_actions_data.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 877883a99a..efa988a2b1 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -35,6 +35,7 @@ def setUpClassBaseData(cls):
(cls.product_d, 10),
]
)
+ cls.picking.scheduled_date = "2020-08-03"
# put product A in a package
cls.move_a = cls.picking.move_lines[0]
cls._fill_stock_for_moves(cls.move_a, in_package=True)
From 57e2a7629d3595fe6592cb564be36a1ebc4c8882 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 13 Aug 2020 09:43:46 +0200
Subject: [PATCH 312/940] bknd: single_pack_transfer remove confirm states
---
shopfloor/services/single_pack_transfer.py | 72 ++++++++++----------
shopfloor/tests/test_single_pack_transfer.py | 20 ++++--
2 files changed, 52 insertions(+), 40 deletions(-)
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 2306432b18..0982093dc0 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -27,30 +27,21 @@ def _response_for_start(self, message=None, popup=None):
return self._response(next_state="start", message=message, popup=popup)
def _response_for_confirm_start(self, package_level, message=None):
- return self._response(
- next_state="confirm_start",
- data=self._data_after_package_scanned(package_level),
- message=message,
- )
-
- def _response_for_scan_location(self, package_level, message=None):
- return self._response(
- next_state="scan_location",
- data=self._data_after_package_scanned(package_level),
- message=message,
- )
+ data = self._data_after_package_scanned(package_level)
+ data["confirmation_required"] = True
+ return self._response(next_state="start", data=data, message=message,)
- def _response_for_confirm_location(self, package_level, message=None):
- return self._response(
- next_state="confirm_location",
- data=self._data_after_package_scanned(package_level),
- message=message,
- )
+ def _response_for_scan_location(
+ self, package_level, message=None, confirmation_required=False
+ ):
+ data = self._data_after_package_scanned(package_level)
+ data["confirmation_required"] = confirmation_required
+ return self._response(next_state="scan_location", data=data, message=message,)
def _response_for_show_completion_info(self, message=None):
return self._response(next_state="show_completion_info", message=message)
- def start(self, barcode):
+ def start(self, barcode, confirmation=False):
search = self.actions_for("search")
picking_types = self.picking_types
location = search.location_from_scan(barcode)
@@ -107,8 +98,7 @@ def start(self, barcode):
return self._response_for_start(
message=self.msg_store.no_pending_operation_for_pack(package)
)
-
- if package_level.is_done:
+ if package_level.is_done and not confirmation:
return self._response_for_confirm_start(
package_level, message=self.msg_store.already_running_ask_confirmation()
)
@@ -188,8 +178,9 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if not scanned_location.is_sublocation_of(move.location_dest_id):
move.location_dest_id = scanned_location.id
else:
- return self._response_for_confirm_location(
+ return self._response_for_scan_location(
package_level,
+ confirmation_required=True,
message=self.msg_store.confirm_location_changed(
move_line.location_dest_id, scanned_location
),
@@ -241,7 +232,10 @@ class SinglePackTransferValidator(Component):
_usage = "single_pack_transfer.validator"
def start(self):
- return {"barcode": {"type": "string", "nullable": False, "required": True}}
+ return {
+ "barcode": {"type": "string", "nullable": False, "required": True},
+ "confirmation": {"type": "boolean", "required": False},
+ }
def cancel(self):
return {
@@ -269,32 +263,40 @@ def _states(self):
With the schema of the data send to the client to transition
to the next state.
"""
+ schema_for_start = self._schema_for_package_level_details()
+ schema_for_start.update(self._schema_confirmation_required())
+ schema_for_scan_location = self._schema_for_package_level_details(required=True)
+ schema_for_scan_location.update(self._schema_confirmation_required())
return {
- "start": {},
- "confirm_start": self._schema_for_package_level_details,
- "scan_location": self._schema_for_package_level_details,
- "confirm_location": self._schema_for_package_level_details,
+ "start": schema_for_start,
+ "scan_location": schema_for_scan_location,
}
def start(self):
- return self._response_schema(next_states={"confirm_start", "scan_location"})
+ return self._response_schema(next_states={"start", "scan_location"})
def cancel(self):
return self._response_schema(next_states={"start"})
def validate(self):
- return self._response_schema(
- next_states={"scan_location", "start", "confirm_location"}
- )
+ return self._response_schema(next_states={"scan_location", "start"})
- @property
- def _schema_for_package_level_details(self):
+ def _schema_for_package_level_details(self, required=False):
# TODO use schemas.package_level (but the "name" moves in "package.name")
return {
- "id": {"required": True, "type": "integer"},
- "name": {"type": "string", "nullable": False, "required": True},
+ "id": {"required": required, "type": "integer"},
+ "name": {"type": "string", "nullable": False, "required": required},
"location_src": {"type": "dict", "schema": self.schemas.location()},
"location_dest": {"type": "dict", "schema": self.schemas.location()},
"product": {"type": "dict", "schema": self.schemas.product()},
"picking": {"type": "dict", "schema": self.schemas.picking()},
}
+
+ def _schema_confirmation_required(self):
+ return {
+ "confirmation_required": {
+ "type": "boolean",
+ "nullable": True,
+ "required": False,
+ },
+ }
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index aec7b9ca9a..c98b8870cf 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -109,7 +109,10 @@ def test_start(self):
self.assert_response(
response,
next_state="scan_location",
- data=self._response_package_level_data(package_level),
+ data=dict(
+ self._response_package_level_data(package_level),
+ confirmation_required=False,
+ ),
)
def test_start_no_operation(self):
@@ -182,6 +185,7 @@ def test_start_no_operation_create(self):
),
"picking": self.data.picking(package_level.picking_id),
"product": self.data.product(self.product_a),
+ "confirmation_required": False,
}
self.assert_response(
@@ -363,13 +367,16 @@ def test_start_already_started(self):
self.assert_response(
response,
- next_state="confirm_start",
+ next_state="start",
message={
"message_type": "warning",
"body": "Operation's already running."
" Would you like to take it over?",
},
- data=self._response_package_level_data(package_level),
+ data=dict(
+ self._response_package_level_data(package_level),
+ confirmation_required=True,
+ ),
)
def test_validate(self):
@@ -601,9 +608,12 @@ def test_validate_location_to_confirm(self):
)
self.assert_response(
response,
- next_state="confirm_location",
+ next_state="scan_location",
message=message,
- data=self._response_package_level_data(package_level),
+ data=dict(
+ self._response_package_level_data(package_level),
+ confirmation_required=True,
+ ),
)
def test_validate_location_with_confirm(self):
From f425f36dc32dbe90fe35212ba6b585da9e2c6e66 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 13 Aug 2020 15:00:39 +0200
Subject: [PATCH 313/940] single_pack_transfer: prevent usage w/out 'move
entire packs'
single_pack_transfer ATM works only with package levels.
If you use it w/ picking types that do not move entire packages
you'll get bad results.
The long term is to do it as in location_content_transfer
which works w/ both move lines and pkg levels.
---
shopfloor/models/shopfloor_menu.py | 26 ++++++++++++++++++++++++++
shopfloor/models/stock_picking_type.py | 9 ++++++++-
2 files changed, 34 insertions(+), 1 deletion(-)
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index f0385f5717..dd3f3de93d 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -64,3 +64,29 @@ def _check_allow_move_create(self):
raise exceptions.ValidationError(
_("Creation of moves is not allowed for menu {}.").format(menu.name)
)
+
+ # ATM the goal is to block using single_pack_transfer (SPT)
+ # w/out moving the full pkg.
+ # Is not optimal, but is mandatory as long as SPT does not work w/ moves
+ # but only w/ package levels.
+ # TODO: add tests.
+ _move_entire_packs_scenario = "single_pack_transfer"
+
+ @api.constrains("scenario", "picking_type_ids")
+ def _check_move_entire_packages(self):
+ _get_scenario_name = self._fields["scenario"].convert_to_export
+ for menu in self:
+ # TODO: these kind of checks should be provided by the scenario itself.
+ bad_picking_types = [
+ x.name for x in menu.picking_type_ids if not x.show_entire_packs
+ ]
+ if menu.scenario in self._move_entire_packs_scenario and bad_picking_types:
+ scenario_name = _get_scenario_name(menu["scenario"], menu)
+ raise exceptions.ValidationError(
+ _(
+ "Scenario `{}` require(s) "
+ "'Move Entire Packages' to be enabled.\n"
+ "These type(s) do not satisfy this constraint: \n{}.\n"
+ "Please, adjust your configuration."
+ ).format(scenario_name, "\n- ".join(bad_picking_types))
+ )
diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py
index cbe326fc8d..042ef4650c 100644
--- a/shopfloor/models/stock_picking_type.py
+++ b/shopfloor/models/stock_picking_type.py
@@ -1,4 +1,4 @@
-from odoo import fields, models
+from odoo import api, fields, models
class StockPickingType(models.Model):
@@ -13,3 +13,10 @@ class StockPickingType(models.Model):
" Discrete order Picking), the zero check step will be activated when"
" a location becomes empty after a move.",
)
+
+ @api.constrains("show_entire_packs")
+ def _check_move_entire_packages(self):
+ menu_items = self.env["shopfloor.menu"].search(
+ [("picking_type_ids", "in", self.ids)]
+ )
+ menu_items._check_move_entire_packages()
From af0c2ab0297f450e2bbbda5da75e341dbc41323d Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 13 Aug 2020 15:03:50 +0200
Subject: [PATCH 314/940] bknd: zone_picking fix complete buffer msg
---
shopfloor/actions/message.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 91bbd908d4..4f46fb111c 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -427,7 +427,7 @@ def lot_is_not_a_package(self, lot):
def buffer_complete(self):
return {
"message_type": "success",
- "body": _("Scanned destination packages processed."),
+ "body": _("All packages processed."),
}
def picking_type_complete(self, picking_type):
From 0b3f3cd914235215a71c9b1280ae697139cef2a7 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 13 Aug 2020 15:50:42 +0200
Subject: [PATCH 315/940] bknd: fix single_pack_transfer take over
Writing on pkg_level.is_done w/out checking if already set
is going to sum up the whole qty as done each time.
Hence a pkg level w/ 100 units done, if scanned 5 times, will have 500 done.
---
shopfloor/services/single_pack_transfer.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 0982093dc0..91a098355f 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -102,8 +102,8 @@ def start(self, barcode, confirmation=False):
return self._response_for_confirm_start(
package_level, message=self.msg_store.already_running_ask_confirmation()
)
-
- package_level.is_done = True
+ if not package_level.is_done:
+ package_level.is_done = True
return self._response_for_scan_location(package_level)
def _create_package_level(self, package):
From 3d50080db3c87d465b864f34bfaff333f5d20475 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 13 Aug 2020 16:53:20 +0200
Subject: [PATCH 316/940] bknd: fix actions data tests
---
shopfloor/tests/test_actions_data.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index efa988a2b1..0e9d12532e 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -48,6 +48,7 @@ def setUpClassBaseData(cls):
# product D is raw
cls.move_d = cls.picking.move_lines[3]
cls._fill_stock_for_moves(cls.move_d)
+ (cls.move_a + cls.move_b + cls.move_c + cls.move_d).write({"priority": "1"})
cls.picking.action_assign()
def assert_schema(self, schema, data):
From bda162888fe25e2c6940f56875cfd39b67d0e06e Mon Sep 17 00:00:00 2001
From: Thierry Ducrest
Date: Thu, 13 Aug 2020 10:15:22 +0200
Subject: [PATCH 317/940] Add packing information by customer
Add a packing information field on partner that will be displayed on the
stock.picking form when the picking type requires it
---
shopfloor/__manifest__.py | 2 ++
shopfloor/models/__init__.py | 1 +
shopfloor/models/res_partner.py | 7 +++++
shopfloor/models/stock_picking.py | 8 ++++++
shopfloor/models/stock_picking_type.py | 5 ++++
shopfloor/views/res_partner.xml | 15 +++++++++++
shopfloor/views/stock_picking_type.xml | 4 +++
shopfloor/views/stock_picking_views.xml | 34 +++++++++++++++++++++++++
8 files changed, 76 insertions(+)
create mode 100644 shopfloor/models/res_partner.py
create mode 100644 shopfloor/views/res_partner.xml
create mode 100644 shopfloor/views/stock_picking_views.xml
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 2d0d521699..7f89d4184a 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -40,10 +40,12 @@
"data/ir_config_parameter_data.xml",
"data/ir_cron_data.xml",
"security/ir.model.access.csv",
+ "views/res_partner.xml",
"views/shopfloor_menu.xml",
"views/stock_picking_type.xml",
"views/stock_location.xml",
"views/stock_move_line.xml",
+ "views/stock_picking_views.xml",
"views/shopfloor_profile_views.xml",
"views/shopfloor_log_views.xml",
"views/menus.xml",
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index 05a95579d3..cb9e4bf257 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -1,3 +1,4 @@
+from . import res_partner
from . import shopfloor_menu
from . import shopfloor_log
from . import stock_picking_type
diff --git a/shopfloor/models/res_partner.py b/shopfloor/models/res_partner.py
new file mode 100644
index 0000000000..b9a8986af0
--- /dev/null
+++ b/shopfloor/models/res_partner.py
@@ -0,0 +1,7 @@
+from odoo import fields, models
+
+
+class ResPartner(models.Model):
+ _inherit = "res.partner"
+
+ shopfloor_packing_info = fields.Text(string="Checkout Packing Information")
diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py
index cdcd93cd08..3549f226ad 100644
--- a/shopfloor/models/stock_picking.py
+++ b/shopfloor/models/stock_picking.py
@@ -12,6 +12,14 @@ class StockPicking(models.Model):
compute="_compute_picking_info",
help="Technical field. Indicates number of move lines included.",
)
+ shopfloor_display_packing_info = fields.Boolean(
+ related="picking_type_id.shopfloor_display_packing_info", readonly=True,
+ )
+ shopfloor_packing_info = fields.Text(
+ string="Packing information",
+ related="partner_id.shopfloor_packing_info",
+ readonly=True,
+ )
@api.depends(
"move_line_ids", "move_line_ids.product_qty", "move_line_ids.product_id.weight"
diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py
index 042ef4650c..600fa3e58b 100644
--- a/shopfloor/models/stock_picking_type.py
+++ b/shopfloor/models/stock_picking_type.py
@@ -13,6 +13,11 @@ class StockPickingType(models.Model):
" Discrete order Picking), the zero check step will be activated when"
" a location becomes empty after a move.",
)
+ shopfloor_display_packing_info = fields.Boolean(
+ string="Display customer packing info",
+ help="For the Shopfloor Checkout/Packing scenarios to display the"
+ " customer packing info.",
+ )
@api.constrains("show_entire_packs")
def _check_move_entire_packages(self):
diff --git a/shopfloor/views/res_partner.xml b/shopfloor/views/res_partner.xml
new file mode 100644
index 0000000000..3152277af6
--- /dev/null
+++ b/shopfloor/views/res_partner.xml
@@ -0,0 +1,15 @@
+
+
+
+ partner.shopfloor.form
+ res.partner
+
+
+
+
+
+
+
+
+
+
diff --git a/shopfloor/views/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml
index ad90a59d5b..81b809c27a 100644
--- a/shopfloor/views/stock_picking_type.xml
+++ b/shopfloor/views/stock_picking_type.xml
@@ -12,6 +12,10 @@
name="shopfloor_zero_check"
attrs="{'invisible': [('shopfloor_menu_ids', '=', [])]}"
/>
+
diff --git a/shopfloor/views/stock_picking_views.xml b/shopfloor/views/stock_picking_views.xml
new file mode 100644
index 0000000000..308071f266
--- /dev/null
+++ b/shopfloor/views/stock_picking_views.xml
@@ -0,0 +1,34 @@
+
+
+
+ stock.picking.shopfloor.form
+ stock.picking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 18bd820f1f249d0b546871ba5bb539cdca867088 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 13 Aug 2020 17:44:04 +0200
Subject: [PATCH 318/940] bknd: include packing info in checkout/select_package
---
shopfloor/models/stock_picking.py | 6 ++---
shopfloor/services/checkout.py | 11 ++++++++-
shopfloor/tests/test_checkout_scan_line.py | 20 ++++++++++++++--
.../test_checkout_select_package_base.py | 24 +++++++++++++++----
4 files changed, 49 insertions(+), 12 deletions(-)
diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py
index 3549f226ad..a47b4d87ec 100644
--- a/shopfloor/models/stock_picking.py
+++ b/shopfloor/models/stock_picking.py
@@ -13,12 +13,10 @@ class StockPicking(models.Model):
help="Technical field. Indicates number of move lines included.",
)
shopfloor_display_packing_info = fields.Boolean(
- related="picking_type_id.shopfloor_display_packing_info", readonly=True,
+ related="picking_type_id.shopfloor_display_packing_info",
)
shopfloor_packing_info = fields.Text(
- string="Packing information",
- related="partner_id.shopfloor_packing_info",
- readonly=True,
+ string="Packing information", related="partner_id.shopfloor_packing_info",
)
@api.depends(
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index b24e9f7b14..edfdda46ab 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -71,10 +71,16 @@ def _response_for_select_package(self, lines, message=None):
data={
"selected_move_lines": self._data_for_move_lines(lines.sorted()),
"picking": self.data.picking(picking),
+ "packing_info": self._data_for_packing_info(picking),
},
message=message,
)
+ def _data_for_packing_info(self, picking):
+ if picking.picking_type_id.shopfloor_display_packing_info:
+ return picking.shopfloor_packing_info or ""
+ return ""
+
def _response_for_select_dest_package(self, picking, move_lines, message=None):
packages = picking.mapped("move_line_ids.package_id") | picking.mapped(
"move_line_ids.result_package_id"
@@ -1181,7 +1187,10 @@ def _states(self):
"select_document": {},
"manual_selection": self._schema_selection_list,
"select_line": self._schema_stock_picking_details,
- "select_package": self._schema_selected_lines,
+ "select_package": dict(
+ self._schema_selected_lines,
+ packing_info={"type": "string", "nullable": True},
+ ),
"change_quantity": self._schema_selected_lines,
"select_dest_package": self._schema_select_package,
"summary": self._schema_summary,
diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py
index e0063f515d..94b4b5d890 100644
--- a/shopfloor/tests/test_checkout_scan_line.py
+++ b/shopfloor/tests/test_checkout_scan_line.py
@@ -3,7 +3,7 @@
class CheckoutScanLineCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
- def _test_scan_line_ok(self, barcode, selected_lines):
+ def _test_scan_line_ok(self, barcode, selected_lines, packing_info=False):
"""Test /scan_line with a valid return
:param barcode: the barcode we scan
@@ -13,7 +13,7 @@ def _test_scan_line_ok(self, barcode, selected_lines):
response = self.service.dispatch(
"scan_line", params={"picking_id": picking.id, "barcode": barcode}
)
- self._assert_selected(response, selected_lines)
+ self._assert_selected(response, selected_lines, packing_info=packing_info)
def test_scan_line_package_ok(self):
picking = self._create_picking(
@@ -29,6 +29,22 @@ def test_scan_line_package_ok(self):
move_line = move1.move_line_ids
self._test_scan_line_ok(move_line.package_id.name, move_line)
+ def test_scan_line_package_ok_packing_info(self):
+ picking = self._create_picking(
+ lines=[(self.product_a, 10), (self.product_b, 10)]
+ )
+ picking.sudo().partner_id.shopfloor_packing_info = "Please do it like this!"
+ picking.sudo().picking_type_id.shopfloor_display_packing_info = True
+ move1 = picking.move_lines[0]
+ move2 = picking.move_lines[1]
+ # put the lines in 2 separate packages (only the first line should be selected
+ # by the package barcode)
+ self._fill_stock_for_moves(move1, in_package=True)
+ self._fill_stock_for_moves(move2, in_package=True)
+ picking.action_assign()
+ move_line = move1.move_line_ids
+ self._test_scan_line_ok(move_line.package_id.name, move_line, packing_info=True)
+
def test_scan_line_package_several_lines_ok(self):
picking = self._create_picking(
lines=[(self.product_a, 10), (self.product_b, 10)]
diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py
index e82ac9f9af..93c7670485 100644
--- a/shopfloor/tests/test_checkout_select_package_base.py
+++ b/shopfloor/tests/test_checkout_select_package_base.py
@@ -1,5 +1,7 @@
class CheckoutSelectPackageMixin:
- def _assert_selected_response(self, response, selected_lines, message=None):
+ def _assert_selected_response(
+ self, response, selected_lines, message=None, packing_info=False
+ ):
picking = selected_lines.mapped("picking_id")
self.assert_response(
response,
@@ -9,12 +11,18 @@ def _assert_selected_response(self, response, selected_lines, message=None):
self._move_line_data(ml) for ml in selected_lines
],
"picking": self._picking_summary_data(picking),
+ "packing_info": picking.shopfloor_packing_info if packing_info else "",
},
message=message,
)
def _assert_selected_qties(
- self, response, selected_lines, lines_quantities, message=None
+ self,
+ response,
+ selected_lines,
+ lines_quantities,
+ message=None,
+ packing_info=False,
):
picking = selected_lines.mapped("picking_id")
deselected_lines = picking.move_line_ids - selected_lines
@@ -23,9 +31,13 @@ def _assert_selected_qties(
self.assertEqual(line.qty_done, quantity)
for line in deselected_lines:
self.assertEqual(line.qty_done, 0, "Lines deselected must have no qty done")
- self._assert_selected_response(response, selected_lines, message=message)
+ self._assert_selected_response(
+ response, selected_lines, message=message, packing_info=packing_info
+ )
- def _assert_selected(self, response, selected_lines, message=None):
+ def _assert_selected(
+ self, response, selected_lines, message=None, packing_info=False
+ ):
picking = selected_lines.mapped("picking_id")
unselected_lines = picking.move_line_ids - selected_lines
for line in selected_lines:
@@ -36,4 +48,6 @@ def _assert_selected(self, response, selected_lines, message=None):
)
for line in unselected_lines:
self.assertEqual(line.qty_done, 0)
- self._assert_selected_response(response, selected_lines, message=message)
+ self._assert_selected_response(
+ response, selected_lines, message=message, packing_info=packing_info
+ )
From dc72c91407b4c6b47a043a9026ffa68f2515d358 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 17 Aug 2020 09:31:10 +0200
Subject: [PATCH 319/940] search action: do not return location with no barcode
defined
---
shopfloor/actions/search.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py
index 9b2beca215..a757257583 100644
--- a/shopfloor/actions/search.py
+++ b/shopfloor/actions/search.py
@@ -13,6 +13,8 @@ class SearchAction(Component):
_usage = "search"
def location_from_scan(self, barcode):
+ if not barcode:
+ return self.env["stock.location"].browse()
return self.env["stock.location"].search([("barcode", "=", barcode)])
def package_from_scan(self, barcode):
From f6154ca620c9c3d45a70b962f516a1cabe0ecfb2 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 17 Aug 2020 09:35:28 +0200
Subject: [PATCH 320/940] zone picking: fix sort filter move lines
Unable to compare `False` and strings.
---
shopfloor/services/zone_picking.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index a2eadd1a92..81292dd388 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -248,11 +248,11 @@ def _find_location_move_lines(
def _sort_key_move_lines(order):
"""Return a `(sort_keys_func, reverse)` tuple for move lines."""
if order == "priority":
- return lambda line: line.move_id.priority, True
+ return lambda line: line.move_id.priority or "", True
elif order == "location":
return (
lambda line: (
- line.location_id.shopfloor_picking_sequence,
+ line.location_id.shopfloor_picking_sequence or "",
line.location_id.name,
),
False,
From 3544a2e7901ddace9ab4f2afbf6d17735486806d Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 17 Aug 2020 11:16:18 +0200
Subject: [PATCH 321/940] location transfer: add a test to reproduce an issue
with several operators
If several operators are working on the same source location, conflicts
with actually processed move lines appears.
---
shopfloor/tests/__init__.py | 1 +
.../test_location_content_transfer_mix.py | 179 ++++++++++++++++++
2 files changed, 180 insertions(+)
create mode 100644 shopfloor/tests/test_location_content_transfer_mix.py
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 8f64051db9..b17bbc0db0 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -38,6 +38,7 @@
from . import test_location_content_transfer_set_destination_all
from . import test_location_content_transfer_single
from . import test_location_content_transfer_set_destination_package_or_line
+from . import test_location_content_transfer_mix
from . import test_zone_picking_base
from . import test_zone_picking_start
from . import test_zone_picking_select_picking_type
diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py
new file mode 100644
index 0000000000..176e6d94ea
--- /dev/null
+++ b/shopfloor/tests/test_location_content_transfer_mix.py
@@ -0,0 +1,179 @@
+from .test_location_content_transfer_base import LocationContentTransferCommonCase
+
+
+class LocationContentTransferMixCase(LocationContentTransferCommonCase):
+ """Tests where we mix location content transfer with other scenarios."""
+
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.zp_menu = cls.env.ref("shopfloor.shopfloor_menu_zone_picking")
+ cls.wh.sudo().delivery_steps = "pick_pack_ship"
+ cls.pack_location = cls.wh.wh_pack_stock_loc_id
+ cls.ship_location = cls.wh.wh_output_stock_loc_id
+ # Allows location content transfer to process PACK picking type
+ cls.menu.sudo().picking_type_ids = cls.wh.pack_type_id
+ cls.wh.pack_type_id.sudo().default_location_dest_id = cls.env.ref(
+ "stock.stock_location_output"
+ )
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.packing_location.sudo().active = True
+ products = cls.product_a
+ for product in products:
+ cls.env["stock.putaway.rule"].sudo().create(
+ {
+ "product_id": product.id,
+ "location_in_id": cls.stock_location.id,
+ "location_out_id": cls.shelf1.id,
+ }
+ )
+
+ # Put product_a quantities in different packages to get
+ # two stock move lines (6 and 4 to satisfy 10 qties)
+ cls.package_1 = cls.env["stock.quant.package"].create({"name": "PACKAGE_1"})
+ cls.package_2 = cls.env["stock.quant.package"].create({"name": "PACKAGE_2"})
+ cls.package_3 = cls.env["stock.quant.package"].create({"name": "PACKAGE_3"})
+ cls._update_qty_in_location(
+ cls.stock_location, cls.product_a, 6, package=cls.package_1
+ )
+ cls._update_qty_in_location(
+ cls.stock_location, cls.product_a, 4, package=cls.package_2
+ )
+ cls._update_qty_in_location(
+ cls.stock_location, cls.product_a, 5, package=cls.package_3
+ )
+ # Create the pick/pack/ship transfers
+ cls.ship_move_a = cls.env["stock.move"].create(
+ {
+ "name": cls.product_a.display_name,
+ "product_id": cls.product_a.id,
+ "product_uom_qty": 15.0,
+ "product_uom": cls.product_a.uom_id.id,
+ "location_id": cls.ship_location.id,
+ "location_dest_id": cls.customer_location.id,
+ "warehouse_id": cls.wh.id,
+ "picking_type_id": cls.wh.out_type_id.id,
+ "procure_method": "make_to_order",
+ "state": "draft",
+ }
+ )
+ cls.ship_move_a._assign_picking()
+ cls.ship_move_a._action_confirm()
+ cls.pack_move_a = cls.ship_move_a.move_orig_ids[0]
+ cls.pick_move_a = cls.pack_move_a.move_orig_ids[0]
+ cls.picking1 = cls.pick_move_a.picking_id
+ cls.picking1.action_assign()
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.zp_menu, profile=self.profile) as work:
+ self.zp_service = work.component(usage="zone_picking")
+
+ def _zone_picking_process_line(self, move_line):
+ picking = move_line.picking_id
+ zone_location = picking.location_id
+ picking_type = picking.picking_type_id
+ move_lines = picking.move_line_ids.filtered(
+ lambda m: m.state not in ("cancel", "done")
+ )
+ # Select the picking type
+ response = self.zp_service.scan_location(barcode=zone_location.barcode)
+ available_picking_type_ids = [
+ r["id"] for r in response["data"]["select_picking_type"]["picking_types"]
+ ]
+ assert picking_type.id in available_picking_type_ids
+ assert "message" not in response
+ # Check the move lines related to the picking type
+ response = self.zp_service.list_move_lines(
+ zone_location_id=zone_location.id,
+ picking_type_id=picking_type.id,
+ order="priority",
+ )
+ available_move_line_ids = [
+ r["id"] for r in response["data"]["select_line"]["move_lines"]
+ ]
+ assert not set(move_lines.ids) - set(available_move_line_ids)
+ assert "message" not in response
+ # Set the destination on the move line
+ qty = move_line.product_uom_qty
+ response = self.zp_service.set_destination(
+ zone_location.id,
+ picking_type.id,
+ move_line.id,
+ self.packing_location.barcode,
+ qty,
+ )
+ assert response["message"]["message_type"] == "success"
+ self.assertEqual(move_line.state, "done")
+ self.assertEqual(move_line.move_id.product_uom_qty, qty)
+
+ def _location_content_transfer_process_line(self, move_line, set_destination=False):
+ pack_location = move_line.location_id
+ out_location = move_line.location_dest_id
+ # Scan the location
+ response = self.service.scan_location(pack_location.barcode)
+ assert response["next_state"] == "scan_destination_all"
+ # Set the destination
+ if set_destination:
+ qty = move_line.product_uom_qty
+ response = self.service.set_destination_all(
+ pack_location.id, out_location.barcode
+ )
+ assert response["message"]["message_type"] == "success"
+ self.assertEqual(move_line.state, "done")
+ self.assertEqual(move_line.move_id.product_uom_qty, qty)
+
+ def test_with_zone_picking(self):
+ """Test the following scenario:
+
+ 1) Operator-1 processes the first pallet with the "zone picking" scenario:
+
+ move1 PICK -> PACK 'done'
+
+ 2) Operator-2 with the "location content transfer" scenario scan
+ the location where this first pallet is (so the move line is still not
+ done, the operator is currently moving the goods to the destination location):
+
+ move1 PACK -> SHIP 'assigned' while the operator is moving it
+
+ 3) Operator-1 process the second pallet with the "zone picking" scenario:
+
+ move2 PICK -> PACK 'done'
+
+ 4) Operator-3 with the "location content transfer" scenario scan
+ the location where this second pallet is, Odoo should return only this
+ second pallet as the first one, even if not fully processed (done)
+ is not physically available in the scanned location.
+
+ move2 PACK -> SHIP 'assigned' is proposed to the operator
+ move1 PACK -> SHIP while still 'assigned' is not proposed to the operator
+ """
+ picking = self.picking1
+ move_lines = picking.move_line_ids
+ pick_move_line1 = move_lines[0]
+ pick_move_line2 = move_lines[1]
+ # Operator-1 process the first pallet with the "zone picking" scenario
+ self._zone_picking_process_line(pick_move_line1)
+ # Operator-2 with the "location content transfer" scenario scan
+ # the location where this first pallet is (so the move line is still not
+ # done, the operator is currently moving the goods to the destination location)
+ pack_move_line1 = pick_move_line1.move_id.move_dest_ids.filtered(
+ lambda m: m.state not in ("cancel", "done")
+ ).move_line_ids.filtered(lambda l: not l.shopfloor_user_id)
+ self._location_content_transfer_process_line(pack_move_line1)
+ # Operator-1 process the second pallet with the "zone picking" scenario
+ self._zone_picking_process_line(pick_move_line2)
+ # Operator-3 with the "location content transfer" scenario scan
+ # the location where this second pallet is
+ pack_move_line2 = pick_move_line2.move_id.move_dest_ids.filtered(
+ lambda m: m.state not in ("cancel", "done")
+ ).move_line_ids.filtered(lambda l: not l.shopfloor_user_id)
+ assert (
+ len(pack_move_line2) == 1
+ ), "Operator-3 should end up with one move line taken from {}".format(
+ pack_move_line2.picking_id.name
+ )
+ self._location_content_transfer_process_line(pack_move_line2)
From ff66d245b05a86899a256c173cd0df685de9dafd Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 17 Aug 2020 11:21:40 +0200
Subject: [PATCH 322/940] location transfer: fix issue while several operators
are working from the same source location
---
shopfloor/services/location_content_transfer.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 02cfe4683d..5ebb6d7c93 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -179,6 +179,7 @@ def _find_location_move_lines_domain(self, location):
("location_id", "=", location.id),
("qty_done", "=", 0),
("state", "in", ("assigned", "partially_available")),
+ ("shopfloor_user_id", "=", False),
]
def _find_location_move_lines(self, location):
@@ -228,6 +229,10 @@ def scan_location(self, barcode):
When move lines and package levels have different destinations, the
first line without package level or package level is sent to the client.
+ The selected move lines to process are bound to the current operator,
+ this will allow another operator to find unprocessed lines in parallel
+ and not overlap with current ones.
+
Transitions:
* start: location not found, ...
* scan_destination_all: if the destination of all the lines and package
@@ -291,6 +296,7 @@ def scan_location(self, barcode):
for line in move_lines:
line.qty_done = line.product_uom_qty
+ line.shopfloor_user_id = self.env.uid
pickings.user_id = self.env.uid
From 73fbbf03c3f4c17e5667a84f38bdd21b5cfce265 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 17 Aug 2020 15:10:07 +0200
Subject: [PATCH 323/940] location transfer: add a test to reproduce an issue
if move lines have different src locations
---
.../test_location_content_transfer_mix.py | 80 +++++++++++++++----
1 file changed, 65 insertions(+), 15 deletions(-)
diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py
index 176e6d94ea..6ab2dd709a 100644
--- a/shopfloor/tests/test_location_content_transfer_mix.py
+++ b/shopfloor/tests/test_location_content_transfer_mix.py
@@ -72,7 +72,7 @@ def setUp(self):
with self.work_on_services(menu=self.zp_menu, profile=self.profile) as work:
self.zp_service = work.component(usage="zone_picking")
- def _zone_picking_process_line(self, move_line):
+ def _zone_picking_process_line(self, move_line, dest_location=None):
picking = move_line.picking_id
zone_location = picking.location_id
picking_type = picking.picking_type_id
@@ -98,13 +98,11 @@ def _zone_picking_process_line(self, move_line):
assert not set(move_lines.ids) - set(available_move_line_ids)
assert "message" not in response
# Set the destination on the move line
+ if not dest_location:
+ dest_location = move_line.location_dest_id
qty = move_line.product_uom_qty
response = self.zp_service.set_destination(
- zone_location.id,
- picking_type.id,
- move_line.id,
- self.packing_location.barcode,
- qty,
+ zone_location.id, picking_type.id, move_line.id, dest_location.barcode, qty,
)
assert response["message"]["message_type"] == "success"
self.assertEqual(move_line.state, "done")
@@ -115,18 +113,30 @@ def _location_content_transfer_process_line(self, move_line, set_destination=Fal
out_location = move_line.location_dest_id
# Scan the location
response = self.service.scan_location(pack_location.barcode)
- assert response["next_state"] == "scan_destination_all"
+ assert response["next_state"] in ("scan_destination_all", "start_single")
# Set the destination
if set_destination:
qty = move_line.product_uom_qty
- response = self.service.set_destination_all(
- pack_location.id, out_location.barcode
- )
- assert response["message"]["message_type"] == "success"
- self.assertEqual(move_line.state, "done")
- self.assertEqual(move_line.move_id.product_uom_qty, qty)
-
- def test_with_zone_picking(self):
+ if response["next_state"] == "scan_destination_all":
+ response = self.service.set_destination_all(
+ pack_location.id, out_location.barcode
+ )
+ assert response["message"]["message_type"] == "success"
+ self.assertEqual(move_line.state, "done")
+ self.assertEqual(move_line.move_id.product_uom_qty, qty)
+ elif response["next_state"] == "start_single":
+ response = self.service.scan_line(
+ pack_location.id, move_line.id, move_line.product_id.barcode
+ )
+ assert response["message"]["message_type"] == "success"
+ response = self.service.set_destination_line(
+ pack_location.id, move_line.id, qty, out_location.barcode
+ )
+ assert response["message"]["message_type"] == "success"
+ assert move_line.state == "done"
+ assert move_line.qty_done == qty
+
+ def test_with_zone_picking1(self):
"""Test the following scenario:
1) Operator-1 processes the first pallet with the "zone picking" scenario:
@@ -177,3 +187,43 @@ def test_with_zone_picking(self):
pack_move_line2.picking_id.name
)
self._location_content_transfer_process_line(pack_move_line2)
+
+ def test_with_zone_picking2(self):
+ """Test the following scenario:
+
+ 1) Operator-1 processes the first pallet with the "zone picking" scenario
+ to move the goods to PACK-1:
+
+ move1 PICK -> PACK-1 'done'
+
+ 2) Operator-1 processes the second pallet with the "zone picking" scenario
+ to move the goods to PACK-2:
+
+ move1 PICK -> PACK-2 'done'
+
+ 3) Operator-2 with the "location content transfer" scenario scan
+ the location where the first pallet is and has to found it:
+
+ move1 PACK-1 -> SHIP
+ """
+ picking = self.picking1
+ move_lines = picking.move_line_ids
+ pick_move_line1 = move_lines[0]
+ pick_move_line2 = move_lines[1]
+ # Operator-1 process the first pallet with the "zone picking" scenario
+ self._zone_picking_process_line(pick_move_line1)
+ # Operator-1 process the second pallet with the "zone picking" scenario
+ dest_location = pick_move_line2.location_dest_id.sudo().copy(
+ {
+ "name": pick_move_line2.location_dest_id.name + "_2",
+ "barcode": pick_move_line2.location_dest_id.barcode + "_2",
+ "location_id": pick_move_line2.location_dest_id.id,
+ }
+ )
+ self._zone_picking_process_line(pick_move_line2, dest_location=dest_location)
+ # Operator-3 with the "location content transfer" scenario scan
+ # the location where the first pallet is
+ pack_move_line2 = pick_move_line2.move_id.move_dest_ids.filtered(
+ lambda m: m.state not in ("cancel", "done")
+ ).move_line_ids.filtered(lambda l: not l.shopfloor_user_id)[0]
+ self._location_content_transfer_process_line(pack_move_line2)
From a93c5bf0abfe5f3ab8e752f39f017d1c0df19c84 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 17 Aug 2020 15:20:36 +0200
Subject: [PATCH 324/940] location transfer: fix issue if move lines have
different src locations
---
shopfloor/services/location_content_transfer.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 5ebb6d7c93..a0e9845899 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -139,7 +139,9 @@ def _next_content(self, pickings):
return next_content
def _router_single_or_all_destination(self, pickings, message=None):
- if len(pickings.mapped("move_line_ids.location_dest_id")) == 1:
+ location_dest = pickings.mapped("move_line_ids.location_dest_id")
+ location_src = pickings.mapped("move_line_ids.location_id")
+ if len(location_dest) == len(location_src) == 1:
return self._response_for_scan_destination_all(pickings, message=message)
else:
return self._response_for_start_single(pickings, message=message)
From 3bce0e4d5be713369943cbb8fa68d1cb431a87ab Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 19 Aug 2020 15:42:35 +0200
Subject: [PATCH 325/940] bknd: constraint 'delivery' to be used w/ entire
packs as well
---
shopfloor/models/shopfloor_menu.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index dd3f3de93d..23b7f0bd9e 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -70,7 +70,7 @@ def _check_allow_move_create(self):
# Is not optimal, but is mandatory as long as SPT does not work w/ moves
# but only w/ package levels.
# TODO: add tests.
- _move_entire_packs_scenario = "single_pack_transfer"
+ _move_entire_packs_scenario = ("single_pack_transfer", "delivery")
@api.constrains("scenario", "picking_type_ids")
def _check_move_entire_packages(self):
From 6e3c924629311fc2e1504f4f8a60c77f14ce028f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Thu, 20 Aug 2020 11:07:25 +0200
Subject: [PATCH 326/940] zone picking: return right amount of operations on
picking types data (#41)
---
shopfloor/actions/data.py | 37 ---------------------
shopfloor/services/schema.py | 4 ---
shopfloor/services/zone_picking.py | 40 +++++++++++++++++++++--
shopfloor/tests/test_zone_picking_base.py | 9 ++---
4 files changed, 40 insertions(+), 50 deletions(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index f2d7d1bd76..b45da63a28 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -234,41 +234,4 @@ def _picking_type_parser(self):
return [
"id",
"name",
- ("lines_count", self._picking_type_lines_count),
- ("picking_count", self._picking_type_picking_count),
- ("priority_lines_count", self._picking_type_priority_lines_count),
- ("priority_picking_count", self._picking_type_priority_picking_count),
]
-
- def _picking_type_lines_count(self, rec, field):
- return self.env["stock.move.line"].search_count(
- [
- ("picking_id.picking_type_id", "=", rec.id),
- ("qty_done", "=", 0),
- ("state", "in", ("assigned", "partially_available")),
- ]
- )
-
- def _picking_type_priority_lines_count(self, rec, field):
- return self.env["stock.move.line"].search_count(
- [
- ("picking_id.picking_type_id", "=", rec.id),
- ("qty_done", "=", 0),
- ("state", "in", ("assigned", "partially_available")),
- ("picking_id.priority", "in", ["2", "3"]),
- ]
- )
-
- def _picking_type_picking_count(self, rec, field):
- return self.env["stock.picking"].search_count(
- [("picking_type_id", "=", rec.id), ("state", "not in", ("cancel", "done"))]
- )
-
- def _picking_type_priority_picking_count(self, rec, field):
- return self.env["stock.picking"].search_count(
- [
- ("picking_type_id", "=", rec.id),
- ("state", "not in", ("cancel", "done")),
- ("priority", "in", ["2", "3"]),
- ]
- )
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index cf0689f480..16da390bb2 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -181,8 +181,4 @@ def picking_type(self):
return {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
- "lines_count": {"type": "float", "required": True},
- "picking_count": {"type": "float", "required": True},
- "priority_lines_count": {"type": "float", "required": True},
- "priority_picking_count": {"type": "float", "required": True},
}
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 81292dd388..26135c74f0 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -180,11 +180,38 @@ def _response_for_unload_set_destination(
)
def _data_for_select_picking_type(self, zone_location, picking_types):
- return {
+ data = {
"zone_location": self.data.location(zone_location),
# available picking types to choose from
"picking_types": self.data.picking_types(picking_types),
}
+ for datum in data["picking_types"]:
+ picking_type = self.env["stock.picking.type"].browse(datum["id"])
+ zone_lines = self._picking_type_zone_lines(zone_location, picking_type)
+ priority_lines = zone_lines.filtered(
+ lambda line: line.picking_id.priority in ["2", "3"]
+ )
+
+ datum.update(
+ {
+ "lines_count": len(zone_lines),
+ "picking_count": len(zone_lines.mapped("picking_id")),
+ "priority_lines_count": len(priority_lines),
+ "priority_picking_count": len(priority_lines.mapped("picking_id")),
+ }
+ )
+ return data
+
+ def _picking_type_zone_lines(self, zone_location, picking_type):
+ return self.env["stock.move.line"].search(
+ [
+ ("location_id", "=", zone_location.id),
+ # we have auto_join on picking_id
+ ("picking_id.picking_type_id", "=", picking_type.id),
+ ("qty_done", "=", 0),
+ ("state", "in", ("assigned", "partially_available")),
+ ]
+ )
def _data_for_move_line(self, zone_location, picking_type, move_line):
return {
@@ -1401,9 +1428,18 @@ def unload_set_destination(self):
@property
def _schema_for_select_picking_type(self):
+ picking_type = self.schemas.picking_type()
+ picking_type.update(
+ {
+ "lines_count": {"type": "float", "required": True},
+ "picking_count": {"type": "float", "required": True},
+ "priority_lines_count": {"type": "float", "required": True},
+ "priority_picking_count": {"type": "float", "required": True},
+ }
+ )
schema = {
"zone_location": self.schemas._schema_dict_of(self.schemas.location()),
- "picking_types": self.schemas._schema_list_of(self.schemas.picking_type()),
+ "picking_types": self.schemas._schema_list_of(picking_type),
}
return schema
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index ddd2897411..097e7e0e15 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -168,14 +168,9 @@ def assert_response_start(self, response, message=None):
def _assert_response_select_picking_type(
self, state, response, zone_location, picking_types, message=None
):
+ data = self.service._data_for_select_picking_type(zone_location, picking_types)
self.assert_response(
- response,
- next_state=state,
- data={
- "zone_location": self.data.location(zone_location),
- "picking_types": self.data.picking_types(picking_types),
- },
- message=message,
+ response, next_state=state, data=data, message=message,
)
def assert_response_select_picking_type(
From 7d32d10182279be44809e09d6af18765303c521c Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 20 Aug 2020 12:29:44 +0200
Subject: [PATCH 327/940] bknd: fix zone_picking handling of set destination
* if qty is partial -> accept only packages as destination
* fix message when nothing has been processed (no location and no pkg)
---
shopfloor/services/zone_picking.py | 95 ++++++++++++++-----
.../test_zone_picking_set_line_destination.py | 63 +++---------
2 files changed, 84 insertions(+), 74 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 26135c74f0..812f74a94e 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1,7 +1,7 @@
import functools
from odoo.fields import first
-from odoo.tools.float_utils import float_compare
+from odoo.tools.float_utils import float_compare, float_is_zero
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
@@ -488,13 +488,15 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit
def _set_destination_location(
self, zone_location, picking_type, move_line, quantity, confirmation, location
):
+ location_changed = False
+ response = None
# Ask confirmation to the user if the scanned location is not in the
# expected ones but is valid (in picking type's default destination)
if not location.is_sublocation_of(move_line.location_dest_id) and (
not confirmation
and location.is_sublocation_of(picking_type.default_location_dest_id)
):
- return self._response_for_set_line_destination(
+ response = self._response_for_set_line_destination(
zone_location,
picking_type,
move_line,
@@ -503,6 +505,7 @@ def _set_destination_location(
),
confirmation_required=True,
)
+ return (location_changed, response)
# A valid location is a sub-location of the original destination, or a
# sub-location of the picking type's default destination location if
# `confirmation is True
@@ -510,20 +513,22 @@ def _set_destination_location(
confirmation
and not location.is_sublocation_of(picking_type.default_location_dest_id)
):
- return self._response_for_set_line_destination(
+ response = self._response_for_set_line_destination(
zone_location,
picking_type,
move_line,
message=self.msg_store.dest_location_not_allowed(),
)
+ return (location_changed, response)
# If no destination package
if not move_line.result_package_id:
- return self._response_for_set_line_destination(
+ response = self._response_for_set_line_destination(
zone_location,
picking_type,
move_line,
message=self.msg_store.dest_package_required(),
)
+ return (location_changed, response)
# destination location set to the scanned one
move_line.location_dest_id = location
# the quantity done is set to the passed quantity
@@ -535,12 +540,14 @@ def _set_destination_location(
# try to re-assign any split move (in case of partial qty)
if "confirmed" in move_line.picking_id.move_lines.mapped("state"):
move_line.picking_id.action_assign()
+ location_changed = True
# Zero check
zero_check = picking_type.shopfloor_zero_check
if zero_check and move_line.location_id.planned_qty_in_location_is_empty():
- return self._response_for_zero_check(
+ response = self._response_for_zero_check(
zone_location, picking_type, move_line.location_id
)
+ return (location_changed, response)
def _is_package_empty(self, package):
return not bool(package.quant_ids)
@@ -555,41 +562,55 @@ def _is_package_already_used(self, package):
)
)
+ def _move_line_compare_qty(self, move_line, qty):
+ rounding = move_line.product_uom_id.rounding
+ return float_compare(
+ qty, move_line.product_uom_qty, precision_rounding=rounding
+ )
+
+ def _move_line_full_qty(self, move_line, qty):
+ rounding = move_line.product_uom_id.rounding
+ return float_is_zero(
+ move_line.product_uom_qty - qty, precision_rounding=rounding
+ )
+
def _set_destination_package(
self, zone_location, picking_type, move_line, quantity, package
):
+ package_changed = False
+ response = None
# A valid package is:
# * an empty package
# * not used as destination for another move line
if not self._is_package_empty(package):
- return self._response_for_set_line_destination(
+ response = self._response_for_set_line_destination(
zone_location,
picking_type,
move_line,
message=self.msg_store.package_not_empty(package),
)
+ return (package_changed, response)
if self._is_package_already_used(package):
- return self._response_for_set_line_destination(
+ response = self._response_for_set_line_destination(
zone_location,
picking_type,
move_line,
message=self.msg_store.package_already_used(package),
)
+ return (package_changed, response)
# the quantity done is set to the passed quantity
# but if we move a partial qty, we need to split the move line
- rounding = move_line.product_uom_id.rounding
- compare = float_compare(
- quantity, move_line.product_uom_qty, precision_rounding=rounding
- )
+ compare = self._move_line_compare_qty(move_line, quantity)
qty_lesser = compare == -1
qty_greater = compare == 1
if qty_greater:
- return self._response_for_set_line_destination(
+ response = self._response_for_set_line_destination(
zone_location,
picking_type,
move_line,
message=self.msg_store.unable_to_pick_more(move_line.product_uom_qty),
)
+ return (package_changed, response)
elif qty_lesser:
# split the move line which will be processed later
remaining = move_line.product_uom_qty - quantity
@@ -605,12 +626,14 @@ def _set_destination_package(
move_line.result_package_id = package
# the field ``shopfloor_user_id`` is updated with the current user
move_line.shopfloor_user_id = self.env.user
+ package_changed = True
# Zero check
zero_check = picking_type.shopfloor_zero_check
if zero_check and move_line.location_id.planned_qty_in_location_is_empty():
- return self._response_for_zero_check(
+ response = self._response_for_zero_check(
zone_location, picking_type, move_line.location_id
)
+ return (package_changed, response)
def set_destination(
self,
@@ -674,29 +697,50 @@ def set_destination(
move_line = self.env["stock.move.line"].browse(move_line_id)
if not move_line.exists():
return self._response_for_start(message=self.msg_store.record_not_found())
+
+ pkg_moved = False
search = self.actions_for("search")
- # When the barcode is a location
- location = search.location_from_scan(barcode)
- if location:
- response = self._set_destination_location(
- zone_location, picking_type, move_line, quantity, confirmation, location
- )
- if response:
- return response
+ accept_only_package = not self._move_line_full_qty(move_line, quantity)
+
+ if not accept_only_package:
+ # When the barcode is a location
+ location = search.location_from_scan(barcode)
+ if location:
+ pkg_moved, response = self._set_destination_location(
+ zone_location,
+ picking_type,
+ move_line,
+ quantity,
+ confirmation,
+ location,
+ )
+ if response:
+ return response
+
# When the barcode is a package
package = search.package_from_scan(barcode)
if package:
location = move_line.location_dest_id
- response = self._set_destination_package(
+ pkg_moved, response = self._set_destination_package(
zone_location, picking_type, move_line, quantity, package
)
if response:
return response
+
+ message = None
+
+ if not pkg_moved and not package and accept_only_package:
+ message = self.msg_store.package_not_found_for_barcode(barcode)
+ return self._response_for_set_line_destination(
+ zone_location, picking_type, move_line, message=message
+ )
+
+ if pkg_moved:
+ message = self.msg_store.confirm_pack_moved()
+
# Process the next line
response = self.list_move_lines(zone_location.id, picking_type.id)
- return self._response(
- base_response=response, message=self.msg_store.confirm_pack_moved(),
- )
+ return self._response(base_response=response, message=message,)
def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero):
"""Confirm or not if the source location of a move has zero qty
@@ -1175,6 +1219,7 @@ def unload_set_destination(
move_lines,
message=self.msg_store.record_not_found(),
)
+
buffer_lines = self._find_buffer_move_lines(
zone_location, picking_type, dest_package=package
)
diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py
index bf2c13f50f..f1414d6823 100644
--- a/shopfloor/tests/test_zone_picking_set_line_destination.py
+++ b/shopfloor/tests/test_zone_picking_set_line_destination.py
@@ -188,13 +188,12 @@ def test_set_destination_location_no_other_move_line_partial_qty(self):
Then the operator move 6 qty on 10, we get:
- move qty 6 (done):
- -> move_line qty 6 from location X
- move qty 4 (assigned):
- -> move_line qty 4 from location Y (remaining)
+ an error because we can move only full qty by location
+ and only a package barcode is allowed on scan.
"""
zone_location = self.zone_location
picking_type = self.picking3.picking_type_id
+ barcode = self.packing_location.barcode
moves_before = self.picking3.move_lines
self.assertEqual(len(moves_before), 1)
self.assertEqual(len(moves_before.move_line_ids), 1)
@@ -207,30 +206,17 @@ def test_set_destination_location_no_other_move_line_partial_qty(self):
"zone_location_id": zone_location.id,
"picking_type_id": picking_type.id,
"move_line_id": move_line.id,
- "barcode": self.packing_location.barcode,
+ "barcode": barcode,
"quantity": 6,
"confirmation": False,
},
)
- # Check picking data (move has been split in two, 6 done and 4 remaining)
- moves_after = self.picking3.move_lines
- self.assertEqual(len(moves_after), 2)
- self.assertEqual(moves_after[0].product_uom_qty, 6)
- self.assertEqual(moves_after[0].state, "done")
- self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0)
- self.assertEqual(moves_after[1].product_uom_qty, 4)
- self.assertEqual(moves_after[1].state, "assigned")
- self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4)
- self.assertEqual(move_line.qty_done, 6)
- # Check response
- move_lines = self.service._find_location_move_lines(zone_location, picking_type)
- move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
- self.assert_response_select_line(
+ self.assert_response_set_line_destination(
response,
zone_location,
picking_type,
- move_lines,
- message=self.service.msg_store.confirm_pack_moved(),
+ move_line,
+ message=self.service.msg_store.package_not_found_for_barcode(barcode),
)
def test_set_destination_location_several_move_line_full_qty(self):
@@ -308,56 +294,35 @@ def test_set_destination_location_several_move_line_partial_qty(self):
Then the operator move 4 qty on 6 (from the first move line), we get:
- move qty 4 (done):
- -> move_line qty 4 from location X
- move qty 2 (assigned):
- -> move_line qty 2 from location X (remaining)
- move qty 4 (assigned):
- -> move_line qty 4 from location Y (untouched)
+ an error because we can move only full qty by location
+ and only a package barcode is allowed on scan.
"""
zone_location = self.zone_location
picking_type = self.picking4.picking_type_id
+ barcode = self.packing_location.barcode
moves_before = self.picking4.move_lines
self.assertEqual(len(moves_before), 1) # 10 qty
self.assertEqual(len(moves_before.move_line_ids), 2) # 6+4 qty
move_line = moves_before.move_line_ids[0]
# we need a destination package if we want to scan a destination location
move_line.result_package_id = self.free_package
- other_move_line = moves_before.move_line_ids[1]
response = self.service.dispatch(
"set_destination",
params={
"zone_location_id": zone_location.id,
"picking_type_id": picking_type.id,
"move_line_id": move_line.id,
- "barcode": self.packing_location.barcode,
+ "barcode": barcode,
"quantity": 4, # 4/6 qty
"confirmation": False,
},
)
- # Check picking data (move has been split in three, 4 done, 2+4 remaining)
- moves_after = self.picking4.move_lines
- self.assertEqual(len(moves_after), 3)
- self.assertEqual(moves_after[0].product_uom_qty, 4)
- self.assertEqual(moves_after[0].state, "done")
- self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0)
- self.assertEqual(moves_after[1].product_uom_qty, 4)
- self.assertEqual(moves_after[1].state, "assigned")
- self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4)
- self.assertEqual(moves_after[2].product_uom_qty, 2)
- self.assertEqual(moves_after[2].state, "assigned")
- self.assertEqual(moves_after[2].move_line_ids.product_uom_qty, 2)
- self.assertEqual(move_line.qty_done, 4)
- self.assertNotEqual(move_line.move_id, other_move_line.move_id)
- # Check response
- move_lines = self.service._find_location_move_lines(zone_location, picking_type)
- move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
- self.assert_response_select_line(
+ self.assert_response_set_line_destination(
response,
zone_location,
picking_type,
- move_lines,
- message=self.service.msg_store.confirm_pack_moved(),
+ move_line,
+ message=self.service.msg_store.package_not_found_for_barcode(barcode),
)
def test_set_destination_location_zero_check(self):
From bd0855fc69813ec59e255c0ead68d5dfa9dc9653 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 20 Aug 2020 12:30:41 +0200
Subject: [PATCH 328/940] zone_picking: handle case where set line dest has no
line
---
shopfloor/services/zone_picking.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 812f74a94e..762658029b 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1266,6 +1266,9 @@ def unload_set_destination(
return self._response_for_start(
message=self.msg_store.picking_type_complete(picking_type)
)
+ # TODO: when we have no lines here
+ # we should not redirect to `unload_set_destination`
+ # because we'll have nothing to display (currently the UI is broken).
return self._response_for_unload_set_destination(
zone_location,
picking_type,
From ed9b3f1dbaf66de1740edddad2df90c81d6f0d8d Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 21 Aug 2020 13:23:05 +0200
Subject: [PATCH 329/940] bknd: fix checkout._response_for_select_package
'picking' is always required.
---
shopfloor/services/checkout.py | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index edfdda46ab..91ba2565b8 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -64,8 +64,7 @@ def _response_for_manual_selection(self, message=None):
data = {"pickings": self.data.pickings(pickings)}
return self._response(next_state="manual_selection", data=data, message=message)
- def _response_for_select_package(self, lines, message=None):
- picking = lines.mapped("picking_id")
+ def _response_for_select_package(self, picking, lines, message=None):
return self._response(
next_state="select_package",
data={
@@ -87,6 +86,7 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None):
)
if not packages:
return self._response_for_select_package(
+ picking,
move_lines,
message={
"message_type": "warning",
@@ -353,7 +353,7 @@ def _select_lines_from_package(self, picking, selection_lines, package):
},
)
self._select_lines(lines)
- return self._response_for_select_package(lines)
+ return self._response_for_select_package(picking, lines)
def _select_lines_from_product(self, picking, selection_lines, product):
if product.tracking in ("lot", "serial"):
@@ -390,7 +390,7 @@ def _select_lines_from_product(self, picking, selection_lines, product):
return self._select_lines_from_package(picking, selection_lines, packages)
self._select_lines(lines)
- return self._response_for_select_package(lines)
+ return self._response_for_select_package(picking, lines)
def _select_lines_from_lot(self, picking, selection_lines, lot):
lines = selection_lines.filtered(lambda l: l.lot_id == lot)
@@ -422,7 +422,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot):
return self._select_lines_from_package(picking, selection_lines, packages)
self._select_lines(lines)
- return self._response_for_select_package(lines)
+ return self._response_for_select_package(picking, lines)
def _select_line_package(self, picking, selection_lines, package):
if not package:
@@ -443,7 +443,7 @@ def _select_line_move_line(self, picking, selection_lines, move_line):
picking, selection_lines, move_line.package_id
)
self._select_lines(move_line)
- return self._response_for_select_package(move_line)
+ return self._response_for_select_package(picking, move_line)
def select_line(self, picking_id, package_id=None, move_line_id=None):
"""Select move lines of the stock picking
@@ -513,6 +513,7 @@ def _change_line_qty(
else:
move_line.qty_done = qty_done
return self._response_for_select_package(
+ picking,
self.env["stock.move.line"].browse(selected_line_ids).exists(),
message=message,
)
@@ -607,6 +608,7 @@ def _put_lines_in_package(self, picking, selected_lines, package):
"""
if not self._is_package_allowed(picking, package):
return self._response_for_select_package(
+ picking,
selected_lines,
message={
"message_type": "error",
@@ -692,6 +694,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
if product:
if product.tracking in ("lot", "serial"):
return self._response_for_select_package(
+ picking,
selected_lines,
message=self.msg_store.scan_lot_on_product_tracked_by_lot(),
)
@@ -714,7 +717,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
)
return self._response_for_select_package(
- selected_lines, message=self.msg_store.barcode_not_found()
+ picking, selected_lines, message=self.msg_store.barcode_not_found()
)
def new_package(self, picking_id, selected_line_ids):
From 18bad292781e41686e65e4372c540503bc857fad Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 21 Aug 2020 14:36:21 +0200
Subject: [PATCH 330/940] zone picking/location content transfer: Add sync of
move lines destinations
---
shopfloor/services/change_pack_lot_mixin.py | 2 ++
shopfloor/services/cluster_picking.py | 4 +--
.../services/location_content_transfer.py | 32 +++++++++++++------
shopfloor/services/zone_picking.py | 13 +++++---
4 files changed, 34 insertions(+), 17 deletions(-)
diff --git a/shopfloor/services/change_pack_lot_mixin.py b/shopfloor/services/change_pack_lot_mixin.py
index bf6cb12f75..e1d9b078d2 100644
--- a/shopfloor/services/change_pack_lot_mixin.py
+++ b/shopfloor/services/change_pack_lot_mixin.py
@@ -1,6 +1,8 @@
from odoo import _
+# TODO use a component instead (in actions)
+# delegation > inheritance ;)
class ChangePackLotMixin:
def _change_lot(self, move_line, lot, response_ok_func, response_error_func):
"""Change the lot on the move line.
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 0871afb452..a1924e1fad 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1074,7 +1074,7 @@ def unload_scan_destination(
batch, package, lines, barcode, confirmation=confirmation
)
- def _unload_scan_destination_lock_lines(self, lines):
+ def _lock_lines(self, lines):
"""Lock move lines"""
sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE" % lines._table
self.env.cr.execute(sql, (tuple(lines.ids),), log_exceptions=False)
@@ -1083,7 +1083,7 @@ def _unload_scan_destination_lines(
self, batch, package, lines, barcode, confirmation=False
):
# Lock move lines that will be updated
- self._unload_scan_destination_lock_lines(lines)
+ self._lock_lines(lines)
first_line = fields.first(lines)
picking_type = fields.first(batch.picking_ids).picking_type_id
scanned_location = self.actions_for("search").location_from_scan(barcode)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index a0e9845899..4a64454e89 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -67,7 +67,7 @@ def _response_for_scan_destination_all(
if confirmation_required and not message:
message = self.msg_store.need_confirmation()
return self._response(
- next_state="scan_destination_all", data=data, message=message,
+ next_state="scan_destination_all", data=data, message=message
)
def _response_for_start_single(self, pickings, message=None):
@@ -97,9 +97,7 @@ def _response_for_scan_destination(
data["confirmation_required"] = confirmation_required
if confirmation_required and not message:
message = self.msg_store.need_confirmation()
- return self._response(
- next_state="scan_destination", data=data, message=message,
- )
+ return self._response(next_state="scan_destination", data=data, message=message)
def _data_content_all_for_location(self, pickings):
sorter = self.actions_for("location_content_transfer.sorter")
@@ -320,11 +318,20 @@ def _find_transfer_move_lines(self, location):
)
return lines
- def _set_destination_lines(self, pickings, move_lines, dest_location):
- move_lines.location_dest_id = dest_location
- move_lines.package_level_id.location_dest_id = dest_location
+ # hook used in module shopfloor_checkout_sync
+ def _write_destination_on_lines(self, lines, location):
+ lines.location_dest_id = location
+ lines.package_level_id.location_dest_id = location
+
+ def _set_all_destination_lines_and_done(self, pickings, move_lines, dest_location):
+ self._write_destination_on_lines(move_lines, dest_location)
pickings.action_done()
+ def _lock_lines(self, lines):
+ """Lock move lines"""
+ sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE" % lines._table
+ self.env.cr.execute(sql, (tuple(lines.ids),), log_exceptions=False)
+
def set_destination_all(self, location_id, barcode, confirmation=False):
"""Scan destination location for all the moves of the location
@@ -359,8 +366,9 @@ def set_destination_all(self, location_id, barcode, confirmation=False):
return self._response_for_scan_destination_all(
pickings, confirmation_required=True
)
+ self._lock_lines(move_lines)
- self._set_destination_lines(pickings, move_lines, scanned_location)
+ self._set_all_destination_lines_and_done(pickings, move_lines, scanned_location)
return self._response_for_start(
message=self.msg_store.location_content_transfer_complete(
@@ -542,13 +550,14 @@ def set_destination_package(
location, package_level, confirmation_required=True
)
package_move_lines = package_level.move_line_ids
+ self._lock_lines(package_move_lines)
package_moves = package_move_lines.mapped("move_id")
for package_move in package_moves:
# Check if there is no other lines linked to the move others than
# the lines related to the package itself. In such case we have to
# split the move to process only the lines related to the package.
package_move.split_other_move_lines(package_move_lines)
- package_level.location_dest_id = scanned_location
+ self._write_destination_on_lines(package_level.move_line_ids, scanned_location)
package_moves.with_context(_sf_no_backorder=True)._action_done()
move_lines = self._find_transfer_move_lines(location)
message = self.msg_store.location_content_transfer_item_complete(
@@ -605,6 +614,9 @@ def set_destination_line(
return self._response_for_scan_destination(
location, move_line, confirmation_required=True
)
+
+ self._lock_lines(move_line)
+
if quantity < move_line.product_uom_qty:
# Update the current move line quantity and
# put the scanned qty (the move line) in its own move
@@ -620,7 +632,7 @@ def set_destination_line(
for remaining_move_line in current_move.move_line_ids:
remaining_move_line.qty_done = remaining_move_line.product_uom_qty
move_line.move_id.split_other_move_lines(move_line)
- move_line.location_dest_id = scanned_location
+ self._write_destination_on_lines(move_line, scanned_location)
move_line.move_id.with_context(_sf_no_backorder=True)._action_done()
move_lines = self._find_transfer_move_lines(location)
message = self.msg_store.location_content_transfer_item_complete(
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 762658029b..e95b00deed 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -530,7 +530,7 @@ def _set_destination_location(
)
return (location_changed, response)
# destination location set to the scanned one
- move_line.location_dest_id = location
+ self._write_destination_on_lines(move_line, location)
# the quantity done is set to the passed quantity
move_line.qty_done = quantity
# if the move has other move lines, it is split to have only this move line
@@ -1056,7 +1056,7 @@ def set_destination_all(
confirmation_required=True,
)
# the scanned location is still valid, use it
- buffer_lines.location_dest_id = location
+ self._write_destination_on_lines(buffer_lines, location)
# set lines to done + refresh buffer lines (should be empty)
moves = buffer_lines.mapped("move_id")
moves.with_context(_sf_no_backorder=True)._action_done()
@@ -1068,6 +1068,10 @@ def set_destination_all(
zone_location, picking_type, buffer_lines, message=message,
)
+ def _write_destination_on_lines(self, lines, location):
+ self._lock_lines(lines)
+ lines.location_dest_id = location
+
def unload_split(self, zone_location_id, picking_type_id):
"""Indicates that now the buffer must be treated line per line
@@ -1177,7 +1181,7 @@ def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcod
unload_single_message=self.msg_store.barcode_no_match(package.name),
)
- def _unload_set_destination_lock_lines(self, lines):
+ def _lock_lines(self, lines):
"""Lock move lines"""
sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE" % lines._table
self.env.cr.execute(sql, (tuple(lines.ids),), log_exceptions=False)
@@ -1245,8 +1249,7 @@ def unload_set_destination(
confirmation_required=True,
)
# the scanned location is valid, use it
- self._unload_set_destination_lock_lines(buffer_lines)
- buffer_lines.location_dest_id = location
+ self._write_destination_on_lines(buffer_lines, location)
# set lines to done + refresh buffer lines (should be empty)
moves = buffer_lines.mapped("move_id")
moves.with_context(_sf_no_backorder=True)._action_done()
From c3765dd07679a1d7598745814f412f03caed9ad1 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 26 Aug 2020 07:46:34 +0200
Subject: [PATCH 331/940] bknd: improve exception tracing
* store full traceback in log entry
* return log entry URL to ease reporting
---
shopfloor/services/service.py | 37 +++++++++++++++++++++++++++++----
shopfloor/services/validator.py | 1 +
2 files changed, 34 insertions(+), 4 deletions(-)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 10f9f088d9..877d561a45 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -1,3 +1,7 @@
+import traceback
+
+from werkzeug.urls import url_encode, url_join
+
from odoo import _, exceptions, registry
from odoo.exceptions import MissingError
from odoo.http import request
@@ -15,6 +19,15 @@ def to_float(val):
return None
+class ShopfloorServiceDispatchException(Exception):
+
+ rest_json_info = {}
+
+ def __init__(self, message, log_entry_url):
+ super().__init__(message)
+ self.rest_json_info = {"log_entry_url": log_entry_url}
+
+
class BaseShopfloorService(AbstractComponent):
"""Base class for REST services"""
@@ -43,14 +56,30 @@ def _dispatch_with_db_logging(self, method_name, _id=None, params=None):
try:
result = super().dispatch(method_name, _id=_id, params=params)
except Exception as err:
+ tb = traceback.format_exc()
self.env.cr.rollback()
with registry(self.env.cr.dbname).cursor() as cr:
env = self.env(cr=cr)
- self._log_call_in_db(env, request, _id, params, error=err)
- raise
- self._log_call_in_db(self.env, request, _id, params, result=result)
+ log_entry = self._log_call_in_db(env, request, _id, params, error=tb)
+ log_entry_url = self._get_log_entry_url(log_entry)
+ raise ShopfloorServiceDispatchException(str(err), log_entry_url) from err
+
+ log_entry = self._log_call_in_db(self.env, request, _id, params, result=result)
+ log_entry_url = self._get_log_entry_url(log_entry)
+ result["log_entry_url"] = log_entry_url
return result
+ def _get_log_entry_url(self, entry):
+ base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
+ url_params = {
+ "action": self.env.ref("shopfloor.action_shopfloor_log").id,
+ "view_type": "form",
+ "model": entry._name,
+ "id": entry.id,
+ }
+ url = "/web?#%s" % url_encode(url_params)
+ return url_join(base_url, url)
+
@property
def _log_call_header_strip(self):
return ("Cookie", "Api-Key")
@@ -79,7 +108,7 @@ def _log_call_in_db(self, env, _request, _id, params, result=None, error=None):
)
if not values:
return
- env["shopfloor.log"].sudo().create(values)
+ return env["shopfloor.log"].sudo().create(values)
def _get(self, _id):
domain = expression.normalize_domain(self._get_base_search_domain())
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
index 8bdf4d75cf..21c46847de 100644
--- a/shopfloor/services/validator.py
+++ b/shopfloor/services/validator.py
@@ -107,6 +107,7 @@ def _response_schema(self, data_schema=None, next_states=None):
"required": False,
"schema": {"body": {"type": "string", "required": True}},
},
+ "log_entry_url": {"type": "string", "required": False},
}
if not data_schema:
data_schema = {}
From 42b4699592b16c9b3d32909a58dc21002e46e580 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 26 Aug 2020 07:48:01 +0200
Subject: [PATCH 332/940] bknd: zone picking fix typo raise/return on scan
source
---
shopfloor/services/zone_picking.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index e95b00deed..baccf9dbd8 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -382,7 +382,7 @@ def _scan_source_location(self, zone_location, picking_type, location):
move_line = self.env["stock.move.line"].search(domain)
if len(move_line) == 1:
return move_line
- raise False
+ return False
def _scan_source_package(self, zone_location, picking_type, package, order):
move_lines = self._find_location_move_lines(
From 5b8d2485ca35e5220e583bc2055960580fc4ccee Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 26 Aug 2020 08:17:33 +0200
Subject: [PATCH 333/940] bknd: add package name unique constrain
---
shopfloor/models/stock_quant_package.py | 8 +++++++-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_misc.py | 20 ++++++++++++++++++++
3 files changed, 28 insertions(+), 1 deletion(-)
create mode 100644 shopfloor/tests/test_misc.py
diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py
index c3bc49511d..b768c06280 100644
--- a/shopfloor/models/stock_quant_package.py
+++ b/shopfloor/models/stock_quant_package.py
@@ -1,4 +1,4 @@
-from odoo import api, fields, models
+from odoo import _, api, exceptions, fields, models
class StockQuantPackage(models.Model):
@@ -37,3 +37,9 @@ def _compute_reserved_move_lines(self):
# destination_planned_move_line_ids
# filter out done/cancel lines
+
+ @api.constrains("name")
+ def _constrain_name_unique(self):
+ for rec in self:
+ if self.search_count([("name", "=", rec.name), ("id", "!=", rec.id)]):
+ raise exceptions.UserError(_("Package name must be unique!"))
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index b17bbc0db0..bfa6c153a4 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -50,3 +50,4 @@
from . import test_zone_picking_unload_single
from . import test_zone_picking_unload_all
from . import test_zone_picking_unload_set_destination
+from . import test_misc
diff --git a/shopfloor/tests/test_misc.py b/shopfloor/tests/test_misc.py
new file mode 100644
index 0000000000..3e29f16b8b
--- /dev/null
+++ b/shopfloor/tests/test_misc.py
@@ -0,0 +1,20 @@
+from odoo import exceptions
+from odoo.tests.common import SavepointCase
+
+
+class MiscTestCase(SavepointCase):
+ tracking_disable = True
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.env = cls.env(
+ context=dict(cls.env.context, tracking_disable=cls.tracking_disable)
+ )
+
+ def test_package_name_unique(self):
+ create = self.env["stock.quant.package"].create
+ create({"name": "GOOD_NAME"})
+ with self.assertRaises(exceptions.UserError) as exc:
+ create({"name": "GOOD_NAME"})
+ self.assertEqual(exc.exception.name, "Package name must be unique!")
From b98ae92aa8deb276a621520f42f120b9c8d29692 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 26 Aug 2020 12:10:09 +0200
Subject: [PATCH 334/940] zone picking: return error when no package or
location is found
---
shopfloor/services/zone_picking.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index baccf9dbd8..d9f36bc3da 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -729,8 +729,12 @@ def set_destination(
message = None
- if not pkg_moved and not package and accept_only_package:
- message = self.msg_store.package_not_found_for_barcode(barcode)
+ if not pkg_moved and not package:
+ if accept_only_package:
+ message = self.msg_store.package_not_found_for_barcode(barcode)
+ else:
+ # we don't know if user wanted to scan a location or a package
+ message = self.msg_store.barcode_not_found()
return self._response_for_set_line_destination(
zone_location, picking_type, move_line, message=message
)
From 38489e11ff14b5de265a29f7e8aa84dbb251bd37 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Tue, 18 Aug 2020 18:42:06 +0200
Subject: [PATCH 335/940] location transfer: ensure to process pickings sharing
the same source location
And test a complete flow:
1) Operator-1 processes the first pallet with the "zone picking" scenario
to move the goods to PACK-1:
move1 PICK -> PACK-1 'done'
2) Operator-1 processes the second pallet with the "zone picking" scenario
to move the goods to PACK-2:
move1 PICK -> PACK-2 'done'
3) Operator-2 with the "location content transfer" scenario scan
the location where the first pallet is (PACK-1):
- the app should found one move line
- this move line will be put in its own transfer as its sibling lines
are in another source location
- as such the app should ask the destination location (as there is
only one line)
move1 PACK-2 -> SHIP (still handled by the operator so not 'done')
4) Operator-3 with the "location content transfer" scenario scan
the location where the first pallet is (PACK-1):
- nothing is found as the pallet is currently handled by Operator-2
5) If Operator-2 is unable to finish the flow with the first pallet
(barcode device out of battery... etc), he should be able to recover
what he started.
6) Operator-2 then finishes its operation regarding the first pallet, and
scan the location where the second pallet is (PACK-2). He should find
only this pallet available.
---
shopfloor/actions/message.py | 6 +
shopfloor/models/stock_move.py | 10 +-
shopfloor/models/stock_move_line.py | 88 +++++++-
.../services/location_content_transfer.py | 18 +-
shopfloor/tests/common.py | 4 +-
.../test_location_content_transfer_base.py | 2 +-
.../test_location_content_transfer_mix.py | 194 +++++++++++++++---
shopfloor/tests/test_stock_split.py | 128 ++++++++++++
8 files changed, 412 insertions(+), 38 deletions(-)
create mode 100644 shopfloor/tests/test_stock_split.py
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 4f46fb111c..b713be668c 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -447,3 +447,9 @@ def lines_different_dest_location(self):
"message_type": "error",
"body": _("Lines have different destination location."),
}
+
+ def new_move_lines_not_assigned(self):
+ return {
+ "message_type": "error",
+ "body": _("New move lines cannot be assigned: canceled."),
+ }
diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py
index 0194457f9d..f700f1bec7 100644
--- a/shopfloor/models/stock_move.py
+++ b/shopfloor/models/stock_move.py
@@ -4,12 +4,18 @@
class StockMove(models.Model):
_inherit = "stock.move"
- def split_other_move_lines(self, move_lines):
+ def split_other_move_lines(self, move_lines, intersection=False):
"""Substract `move_lines` from `move.move_line_ids`, put the result
in a new move and returns it.
+
+ If `intersection` is set to `True`, this is the common lines between
+ `move_lines` and `move.move_line_ids` which will be put in a new move.
"""
self.ensure_one()
- other_move_lines = self.move_line_ids - move_lines
+ if intersection:
+ other_move_lines = self.move_line_ids & move_lines
+ else:
+ other_move_lines = self.move_line_ids - move_lines
if other_move_lines:
qty_to_split = sum(other_move_lines.mapped("product_uom_qty"))
backorder_move_id = self._split(qty_to_split)
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index 7bb68b899c..f92dedea7a 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -1,4 +1,5 @@
-from odoo import fields, models
+from odoo import _, fields, models
+from odoo.exceptions import UserError
class StockMoveLine(models.Model):
@@ -21,3 +22,88 @@ class StockMoveLine(models.Model):
# allow domain on picking_id.xxx without too much perf penalty
picking_id = fields.Many2one(auto_join=True)
+
+ def _split_pickings_from_source_location(self):
+ """Ensure that the related pickings will have the same source location.
+
+ Some pickings related could have other unrelated move lines, as such we
+ have to split them to contain only the move lines related to the expected
+ source location.
+
+ Example:
+
+ Initial data:
+
+ PICK1:
+ - move line with source location LOC1
+ - move line with source location LOC2
+ PICK2:
+ - move line with source location LOC2
+ - move line with source location LOC3
+
+ Then we process move lines related to LOC2 with this method, we get:
+
+ PICK1:
+ - move line with source location LOC1
+ PICK2:
+ - move line with source location LOC3
+ PICK3:
+ - move line with source location LOC2
+ - move line with source location LOC2
+
+ Return the new picking (in case a split has been made), or the current
+ related pickings.
+ """
+ location_src_to_process = self.location_id
+ if location_src_to_process and len(location_src_to_process) != 1:
+ raise UserError(
+ _("Move lines processed have to share the same source location.")
+ )
+ pickings = self.picking_id
+ move_lines_to_process_ids = []
+ for picking in pickings:
+ location_src = picking.mapped("move_line_ids.location_id")
+ if len(location_src) == 1:
+ continue
+ # Get the related move lines among the picking and split them
+ move_lines_to_process_ids.extend(
+ set(picking.move_line_ids.ids) & set(self.ids)
+ )
+ # Put all move lines related to the source location in a separate picking
+ move_lines_to_process = self.browse(move_lines_to_process_ids)
+ new_move_ids = []
+ for move_line in move_lines_to_process:
+ new_move = move_line.move_id.split_other_move_lines(
+ move_line, intersection=True
+ )
+ new_move._recompute_state()
+ new_move_ids.append(new_move.id)
+ # If we have new moves, create the backorder picking
+ # NOTE: code copy/pasted & adapted from OCA module 'stock_split_picking'
+ new_moves = self.env["stock.move"].browse(new_move_ids)
+ if new_moves:
+ picking = pickings[0]
+ new_picking = picking.copy(
+ {
+ "name": "/",
+ "move_lines": [],
+ "move_line_ids": [],
+ "backorder_id": picking.id,
+ }
+ )
+ pickings.message_post(
+ body=_(
+ 'The backorder %s has been created.'
+ )
+ % (new_picking.id, new_picking.name)
+ )
+ new_moves.write({"picking_id": new_picking.id})
+ new_moves.mapped("move_line_ids").write({"picking_id": new_picking.id})
+ new_moves.move_line_ids.package_level_id.write(
+ {"picking_id": new_picking.id}
+ )
+ new_moves._action_assign()
+ pickings = new_picking
+ return pickings
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 4a64454e89..5552dae16a 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -243,7 +243,7 @@ def scan_location(self, barcode):
if not location:
return self._response_for_start(message=self.msg_store.barcode_not_found())
move_lines = self._find_location_move_lines(location)
- pickings = move_lines.mapped("picking_id")
+ pickings = move_lines.picking_id
picking_types = pickings.mapped("picking_type_id")
if len(picking_types) > 1:
@@ -260,6 +260,13 @@ def scan_location(self, barcode):
"body": _("This location content can't be moved using this menu."),
}
)
+ # Ensure we process move lines related to pickings having only one source
+ # location among all their move lines. If there are different source
+ # locations, we put the move lines we are interested in in a separate picking.
+ # This is required as we can only deal within this scenario with pickings
+ # that share the same source location.
+ pickings = move_lines._split_pickings_from_source_location()
+
# If the following criteria are met:
# - no move lines have been found
# - the menu is configured to allow the creation of moves
@@ -276,15 +283,16 @@ def scan_location(self, barcode):
)
):
new_moves = self._create_moves_from_location(location)
+ if not new_moves:
+ return self._response_for_start(
+ message=self.msg_store.no_pack_in_location(location)
+ )
new_moves._action_confirm(merge=False)
new_moves._action_assign()
if not all([x.state == "assigned" for x in new_moves]):
new_moves._action_cancel()
return self._response_for_start(
- message={
- "message_type": "error",
- "body": _("New move lines cannot be assigned: canceled."),
- }
+ message=self.msg_store.new_move_lines_not_assigned()
)
pickings = new_moves.mapped("picking_id")
move_lines = new_moves.move_line_ids
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index b55dc7f269..7569e5aa4b 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -52,9 +52,9 @@ class CommonCase(SavepointCase, ComponentMixin):
maxDiff = None
@contextmanager
- def work_on_services(self, **params):
+ def work_on_services(self, env=None, **params):
params = params or {}
- collection = _PseudoCollection("shopfloor.service", self.env)
+ collection = _PseudoCollection("shopfloor.service", env or self.env)
yield WorkContext(
model_name="rest.service.registration", collection=collection, **params
)
diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py
index 0bf62b4ef7..6c89326b34 100644
--- a/shopfloor/tests/test_location_content_transfer_base.py
+++ b/shopfloor/tests/test_location_content_transfer_base.py
@@ -57,7 +57,7 @@ def _assert_response_scan_destination_all(
# data methods have their own tests
lines = pickings.move_line_ids.filtered(lambda line: not line.package_level_id)
package_levels = pickings.package_level_ids
- location = lines.mapped("location_id")
+ location = pickings.mapped("move_line_ids.location_id")
self.assert_response(
response,
next_state=state,
diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py
index 6ab2dd709a..b4c6252dba 100644
--- a/shopfloor/tests/test_location_content_transfer_mix.py
+++ b/shopfloor/tests/test_location_content_transfer_mix.py
@@ -1,9 +1,30 @@
+# Copyright 2020 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
from .test_location_content_transfer_base import LocationContentTransferCommonCase
class LocationContentTransferMixCase(LocationContentTransferCommonCase):
"""Tests where we mix location content transfer with other scenarios."""
+ @classmethod
+ def setUpClassUsers(cls):
+ super().setUpClassUsers()
+ Users = (
+ cls.env["res.users"]
+ .sudo()
+ .with_context({"no_reset_password": True, "mail_create_nosubscribe": True})
+ )
+ cls.stock_user2 = Users.create(
+ {
+ "name": "Paul Posichon",
+ "login": "paulposichon",
+ "email": "paul.posichon@example.com",
+ "notification_type": "inbox",
+ "groups_id": [(6, 0, [cls.env.ref("stock.group_stock_user").id])],
+ }
+ )
+
@classmethod
def setUpClassVars(cls, *args, **kwargs):
super().setUpClassVars(*args, **kwargs)
@@ -35,22 +56,18 @@ def setUpClassBaseData(cls):
# two stock move lines (6 and 4 to satisfy 10 qties)
cls.package_1 = cls.env["stock.quant.package"].create({"name": "PACKAGE_1"})
cls.package_2 = cls.env["stock.quant.package"].create({"name": "PACKAGE_2"})
- cls.package_3 = cls.env["stock.quant.package"].create({"name": "PACKAGE_3"})
cls._update_qty_in_location(
cls.stock_location, cls.product_a, 6, package=cls.package_1
)
cls._update_qty_in_location(
cls.stock_location, cls.product_a, 4, package=cls.package_2
)
- cls._update_qty_in_location(
- cls.stock_location, cls.product_a, 5, package=cls.package_3
- )
# Create the pick/pack/ship transfers
cls.ship_move_a = cls.env["stock.move"].create(
{
"name": cls.product_a.display_name,
"product_id": cls.product_a.id,
- "product_uom_qty": 15.0,
+ "product_uom_qty": 10.0,
"product_uom": cls.product_a.uom_id.id,
"location_id": cls.ship_location.id,
"location_dest_id": cls.customer_location.id,
@@ -65,6 +82,7 @@ def setUpClassBaseData(cls):
cls.pack_move_a = cls.ship_move_a.move_orig_ids[0]
cls.pick_move_a = cls.pack_move_a.move_orig_ids[0]
cls.picking1 = cls.pick_move_a.picking_id
+ cls.packing1 = cls.pack_move_a.picking_id
cls.picking1.action_assign()
def setUp(self):
@@ -102,39 +120,59 @@ def _zone_picking_process_line(self, move_line, dest_location=None):
dest_location = move_line.location_dest_id
qty = move_line.product_uom_qty
response = self.zp_service.set_destination(
- zone_location.id, picking_type.id, move_line.id, dest_location.barcode, qty,
+ zone_location.id,
+ picking_type.id,
+ move_line.id,
+ dest_location.barcode,
+ qty,
+ confirmation=True,
)
assert response["message"]["message_type"] == "success"
self.assertEqual(move_line.state, "done")
self.assertEqual(move_line.move_id.product_uom_qty, qty)
- def _location_content_transfer_process_line(self, move_line, set_destination=False):
+ def _location_content_transfer_process_line(
+ self, move_line, set_destination=False, user=None
+ ):
+ service = self.service
+ if user:
+ env = self.env(user=user)
+ with self.work_on_services(
+ env=env, menu=self.menu, profile=self.profile
+ ) as work:
+ service = work.component(usage="location_content_transfer")
pack_location = move_line.location_id
out_location = move_line.location_dest_id
# Scan the location
- response = self.service.scan_location(pack_location.barcode)
- assert response["next_state"] in ("scan_destination_all", "start_single")
+ response = service.scan_location(pack_location.barcode)
# Set the destination
if set_destination:
+ assert response["next_state"] in ("scan_destination_all", "start_single")
qty = move_line.product_uom_qty
if response["next_state"] == "scan_destination_all":
- response = self.service.set_destination_all(
+ response = service.set_destination_all(
pack_location.id, out_location.barcode
)
- assert response["message"]["message_type"] == "success"
+ self.assert_response_start(
+ response,
+ message=service.msg_store.location_content_transfer_complete(
+ pack_location, out_location,
+ ),
+ )
self.assertEqual(move_line.state, "done")
self.assertEqual(move_line.move_id.product_uom_qty, qty)
elif response["next_state"] == "start_single":
- response = self.service.scan_line(
+ response = service.scan_line(
pack_location.id, move_line.id, move_line.product_id.barcode
)
assert response["message"]["message_type"] == "success"
- response = self.service.set_destination_line(
+ response = service.set_destination_line(
pack_location.id, move_line.id, qty, out_location.barcode
)
assert response["message"]["message_type"] == "success"
assert move_line.state == "done"
assert move_line.qty_done == qty
+ return response
def test_with_zone_picking1(self):
"""Test the following scenario:
@@ -202,28 +240,130 @@ def test_with_zone_picking2(self):
move1 PICK -> PACK-2 'done'
3) Operator-2 with the "location content transfer" scenario scan
- the location where the first pallet is and has to found it:
+ the location where the first pallet is (PACK-1):
+ - the app should found one move line
+ - this move line will be put in its own transfer as its sibling lines
+ are in another source location
+ - as such the app should ask the destination location (as there is
+ only one line)
+
+ move1 PACK-2 -> SHIP (still handled by the operator so not 'done')
+
+ 4) Operator-3 with the "location content transfer" scenario scan
+ the location where the first pallet is (PACK-1):
+ - nothing is found as the pallet is currently handled by Operator-2
+
+ 5) If Operator-2 is unable to finish the flow with the first pallet
+ (barcode device out of battery... etc), he should be able to recover
+ what he started.
- move1 PACK-1 -> SHIP
+ 6) Operator-2 then finishes its operation regarding the first pallet, and
+ scan the location where the second pallet is (PACK-2). He should find
+ only this pallet available.
"""
- picking = self.picking1
- move_lines = picking.move_line_ids
+ move_lines = self.picking1.move_line_ids
pick_move_line1 = move_lines[0]
pick_move_line2 = move_lines[1]
# Operator-1 process the first pallet with the "zone picking" scenario
- self._zone_picking_process_line(pick_move_line1)
+ orig_dest_location = pick_move_line2.location_dest_id
+ dest_location1 = pick_move_line2.location_dest_id.sudo().copy(
+ {
+ "name": orig_dest_location.name + "_1",
+ "barcode": orig_dest_location.barcode + "_1",
+ "location_id": orig_dest_location.id,
+ }
+ )
+ self._zone_picking_process_line(pick_move_line1, dest_location=dest_location1)
# Operator-1 process the second pallet with the "zone picking" scenario
- dest_location = pick_move_line2.location_dest_id.sudo().copy(
+ dest_location2 = orig_dest_location.sudo().copy(
{
- "name": pick_move_line2.location_dest_id.name + "_2",
- "barcode": pick_move_line2.location_dest_id.barcode + "_2",
- "location_id": pick_move_line2.location_dest_id.id,
+ "name": orig_dest_location.name + "_2",
+ "barcode": orig_dest_location.barcode + "_2",
+ "location_id": orig_dest_location.id,
}
)
- self._zone_picking_process_line(pick_move_line2, dest_location=dest_location)
+ self._zone_picking_process_line(pick_move_line2, dest_location=dest_location2)
+ pack_move_a = pick_move_line1.move_id.move_dest_ids.filtered(
+ lambda m: m.state not in ("cancel", "done")
+ )
+ self.assertEqual(pack_move_a, self.pack_move_a)
+ pack_first_pallet = pack_move_a.move_line_ids.filtered(
+ lambda l: not l.shopfloor_user_id and l.location_id == dest_location1
+ )
+ self.assertEqual(pack_first_pallet.product_uom_qty, 4)
+ self.assertEqual(pack_first_pallet.qty_done, 0)
+ pack_second_pallet = pack_move_a.move_line_ids.filtered(
+ lambda l: not l.shopfloor_user_id and l.location_id == dest_location2
+ )
+ self.assertEqual(pack_second_pallet.product_uom_qty, 6)
+ self.assertEqual(pack_second_pallet.qty_done, 0)
+ # Operator-2 with the "location content transfer" scenario scan
+ # the location where the first pallet is.
+ # This pallet/move line will be put in its own transfer as its sibling
+ # lines are in another source location.
+ previous_picking = pack_first_pallet.picking_id
+ response = self._location_content_transfer_process_line(pack_first_pallet)
+ new_picking = pack_first_pallet.picking_id
+ self.assertTrue(previous_picking != new_picking)
+ self.assert_response_scan_destination_all(response, new_picking)
+ response_packages = response["data"]["scan_destination_all"]["package_levels"]
+ self.assertEqual(len(response_packages), 1)
+ self.assertEqual(
+ response_packages[0]["package_src"]["id"], pack_first_pallet.package_id.id
+ )
+ # Ensure that the second pallet is untouched
+ self.assertEqual(pack_second_pallet.qty_done, 0)
# Operator-3 with the "location content transfer" scenario scan
- # the location where the first pallet is
- pack_move_line2 = pick_move_line2.move_id.move_dest_ids.filtered(
+ # the location where the first pallet is: he should found nothing
+ response = self._location_content_transfer_process_line(
+ pack_first_pallet, user=self.stock_user2
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.new_move_lines_not_assigned()
+ )
+ # Check if Operator-2 is able to recover its session
+ expected_picking = pack_first_pallet.picking_id
+ response = self.service.start_or_recover()
+ self.assert_response_scan_destination_all(
+ response,
+ expected_picking,
+ message=self.service.msg_store.recovered_previous_session(),
+ )
+ # Operator-2 finishes its operation regarding the first pallet
+ qty = pack_first_pallet.product_uom_qty
+ response = self.service.set_destination_all(
+ pack_first_pallet.location_id.id, pack_first_pallet.location_dest_id.barcode
+ )
+ self.assert_response_start(
+ response,
+ message=self.service.msg_store.location_content_transfer_complete(
+ pack_first_pallet.location_id, pack_first_pallet.location_dest_id,
+ ),
+ )
+ self.assertEqual(pack_first_pallet.qty_done, 4)
+ self.assertEqual(pack_first_pallet.state, "done")
+ self.assertEqual(pack_first_pallet.move_id.product_uom_qty, qty)
+ # Ensure that the second pallet is untouched
+ self.assertEqual(pack_second_pallet.qty_done, 0)
+ # Operator-2 (still with the "location content transfer" scenario) scan
+ # the location where the second pallet is
+ pack_move_a = pick_move_line2.move_id.move_dest_ids.filtered(
lambda m: m.state not in ("cancel", "done")
- ).move_line_ids.filtered(lambda l: not l.shopfloor_user_id)[0]
- self._location_content_transfer_process_line(pack_move_line2)
+ )
+ self.assertEqual(pack_move_a, self.pack_move_a)
+ pack_second_pallet = pack_move_a.move_line_ids.filtered(
+ lambda l: not l.shopfloor_user_id and l.location_id == dest_location2
+ )
+ picking_before = pack_second_pallet.picking_id
+ move_lines = self.service._find_location_move_lines(
+ pack_second_pallet.location_id
+ )
+ response = self._location_content_transfer_process_line(pack_second_pallet)
+ response_packages = response["data"]["scan_destination_all"]["package_levels"]
+ self.assertEqual(len(response_packages), 1)
+ self.assertEqual(
+ response_packages[0]["package_src"]["id"], pack_second_pallet.package_id.id
+ )
+ picking_after = pack_second_pallet.picking_id
+ self.assertTrue(picking_before == picking_after) # no picking split
+ self.assert_response_scan_destination_all(response, picking_after)
diff --git a/shopfloor/tests/test_stock_split.py b/shopfloor/tests/test_stock_split.py
new file mode 100644
index 0000000000..9a3ac8e852
--- /dev/null
+++ b/shopfloor/tests/test_stock_split.py
@@ -0,0 +1,128 @@
+from odoo.tests import tagged
+from odoo.tests.common import SavepointCase
+
+
+@tagged("post_install", "-at_install")
+class TestStockSplit(SavepointCase):
+ @classmethod
+ def setUpClass(cls):
+ super(TestStockSplit, cls).setUpClass()
+ cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
+ cls.warehouse = cls.env.ref("stock.warehouse0")
+ cls.warehouse.delivery_steps = "pick_pack_ship"
+ cls.customer_location = cls.env.ref("stock.stock_location_customers")
+ cls.pack_location = cls.warehouse.wh_pack_stock_loc_id
+ cls.ship_location = cls.warehouse.wh_output_stock_loc_id
+ cls.stock_location = cls.env.ref("stock.stock_location_stock")
+ # Create a product
+ cls.product_a = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product A",
+ "type": "product",
+ "default_code": "A",
+ "barcode": "A",
+ "weight": 2,
+ }
+ )
+ )
+ cls.product_a_packaging = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "name": "Box",
+ "product_id": cls.product_a.id,
+ "barcode": "ProductABox",
+ }
+ )
+ )
+ # Put product_a quantities in different packages to get several move lines
+ cls.package_1 = cls.env["stock.quant.package"].create({"name": "PACKAGE_1"})
+ cls.package_2 = cls.env["stock.quant.package"].create({"name": "PACKAGE_2"})
+ cls.package_3 = cls.env["stock.quant.package"].create({"name": "PACKAGE_3"})
+ cls.package_4 = cls.env["stock.quant.package"].create({"name": "PACKAGE_4"})
+ cls._update_qty_in_location(
+ cls.stock_location, cls.product_a, 6, package=cls.package_1
+ )
+ cls._update_qty_in_location(
+ cls.stock_location, cls.product_a, 4, package=cls.package_2
+ )
+ cls._update_qty_in_location(
+ cls.stock_location, cls.product_a, 5, package=cls.package_3
+ )
+ # Create the pick/pack/ship transfer
+ cls.ship_move_a = cls.env["stock.move"].create(
+ {
+ "name": cls.product_a.display_name,
+ "product_id": cls.product_a.id,
+ "product_uom_qty": 15.0,
+ "product_uom": cls.product_a.uom_id.id,
+ "location_id": cls.ship_location.id,
+ "location_dest_id": cls.customer_location.id,
+ "warehouse_id": cls.warehouse.id,
+ "picking_type_id": cls.warehouse.out_type_id.id,
+ "procure_method": "make_to_order",
+ "state": "draft",
+ }
+ )
+ cls.ship_move_a._assign_picking()
+ cls.ship_move_a._action_confirm(merge=False)
+ cls.pack_move = cls.ship_move_a.move_orig_ids[0]
+ cls.pick_move = cls.pack_move.move_orig_ids[0]
+ cls.picking = cls.pick_move.picking_id
+ cls.packing = cls.pack_move.picking_id
+ cls.picking.action_assign()
+
+ @classmethod
+ def _update_qty_in_location(
+ cls, location, product, quantity, package=None, lot=None
+ ):
+ quants = cls.env["stock.quant"]._gather(
+ product, location, lot_id=lot, package_id=package, strict=True
+ )
+ # this method adds the quantity to the current quantity, so remove it
+ quantity -= sum(quants.mapped("quantity"))
+ cls.env["stock.quant"]._update_available_quantity(
+ product, location, quantity, package_id=package, lot_id=lot
+ )
+
+ def test_split_pickings_from_source_location(self):
+ dest_location = self.pick_move.location_dest_id.sudo().copy(
+ {
+ "name": self.pick_move.location_dest_id.name + "_2",
+ "barcode": self.pick_move.location_dest_id.barcode + "_2",
+ "location_id": self.pick_move.location_dest_id.id,
+ }
+ )
+ # Pick goods from stock and move some of them to a different destination
+ self.assertEqual(self.pick_move.state, "assigned")
+ for i, move_line in enumerate(self.pick_move.move_line_ids):
+ move_line.qty_done = move_line.product_uom_qty
+ if i % 2:
+ move_line.location_dest_id = dest_location
+ self.pick_move.with_context(_sf_no_backorder=True)._action_done()
+ self.assertEqual(self.pick_move.state, "done")
+ # Pack step, we want to split move lines from common source location
+ self.assertEqual(self.pack_move.state, "assigned")
+ move_lines_to_process = self.pack_move.move_line_ids.filtered(
+ lambda ml: ml.location_id == dest_location
+ )
+ self.assertEqual(len(self.pack_move.move_line_ids), 3)
+ self.assertEqual(len(self.packing.package_level_ids), 3)
+ self.assertEqual(len(move_lines_to_process), 1)
+ new_packing = move_lines_to_process._split_pickings_from_source_location()
+ self.assertEqual(len(self.packing.package_level_ids), 2)
+ self.assertEqual(len(new_packing.package_level_ids), 1)
+ self.assertEqual(len(new_packing.move_line_ids), 1)
+ self.assertTrue(new_packing != self.packing)
+ self.assertEqual(new_packing.backorder_id, self.packing)
+ self.assertEqual(
+ self.pick_move.move_dest_ids.picking_id, self.packing | new_packing
+ )
+ self.assertEqual(move_lines_to_process.state, "assigned")
+ self.assertEqual(
+ set(self.pack_move.move_line_ids.mapped("state")), {"assigned"}
+ )
From 618c33624846f092be2ea5deaeeecc2e1ca2faad Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 26 Aug 2020 13:15:15 +0200
Subject: [PATCH 336/940] api: return related move line's location instead of
picking's
---
shopfloor/actions/data.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index b45da63a28..d8173033a8 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -1,3 +1,5 @@
+from odoo import fields
+
from odoo.addons.component.core import Component
@@ -170,7 +172,11 @@ def _package_level_parser(self):
("location_dest_id:location_dest", self._location_parser),
(
"location_id:location_src",
- lambda rec, fname: self.location(rec.picking_id.location_id),
+ lambda rec, fname: self.location(
+ fields.first(rec.move_line_ids).location_id
+ or fields.first(rec.move_ids).location_id
+ or rec.picking_id.location_id
+ ),
),
# tnx to stock_quant_package_product_packaging
(
From 18e86e8f7ce077822254df02b51760282410c023 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 26 Aug 2020 15:41:10 +0200
Subject: [PATCH 337/940] bknd: fix zone picking types counters
---
shopfloor/services/zone_picking.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index d9f36bc3da..1150b68599 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -205,7 +205,7 @@ def _data_for_select_picking_type(self, zone_location, picking_types):
def _picking_type_zone_lines(self, zone_location, picking_type):
return self.env["stock.move.line"].search(
[
- ("location_id", "=", zone_location.id),
+ ("location_id", "child_of", zone_location.id),
# we have auto_join on picking_id
("picking_id.picking_type_id", "=", picking_type.id),
("qty_done", "=", 0),
From b5d332bcd6a3f4ecc75e15a741795ab5380349ee Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 26 Aug 2020 14:13:49 +0200
Subject: [PATCH 338/940] location transfer: exit with proper message when no
line is found
in set_destination_all, likely because someone did it meanwhile
---
shopfloor/services/location_content_transfer.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 5552dae16a..0222626737 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -354,6 +354,10 @@ def set_destination_all(self, location_id, barcode, confirmation=False):
return self._response_for_start(message=self.msg_store.record_not_found())
move_lines = self._find_transfer_move_lines(location)
pickings = move_lines.mapped("picking_id")
+ if not pickings:
+ # if we can't find the lines anymore, they likely have been done
+ # by someone else
+ return self._response_for_start(message=self.msg_store.already_done())
scanned_location = self.actions_for("search").location_from_scan(barcode)
if not scanned_location:
return self._response_for_scan_destination_all(
From 0f2600cfcdc18982c0546093d2cc7c8c350bf716 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 26 Aug 2020 14:54:48 +0200
Subject: [PATCH 339/940] backend: Write destintation location on package
levels
In every place that write a destination location on move lines,
ensure we write it on the package level too.
If the lines have no package level, it'll only be an empty write.
---
shopfloor/services/cluster_picking.py | 1 +
shopfloor/services/single_pack_transfer.py | 4 +++-
shopfloor/services/zone_picking.py | 1 +
3 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index a1924e1fad..a1867cb7db 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -957,6 +957,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
def _unload_write_destination_on_lines(self, lines, location):
lines.write({"shopfloor_unloaded": True, "location_dest_id": location.id})
+ lines.package_level_id.location_dest_id = location
for line in lines:
# We set the picking to done only when the last line is
# unloaded to avoid backorders.
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 91a098355f..308a420b94 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -204,7 +204,9 @@ def _router_validate_success(self, package_level):
return self._response_for_start(message=message, popup=completion_info_popup)
def _set_destination_and_done(self, move, scanned_location):
- move.move_line_ids[0].location_dest_id = scanned_location.id
+ # when writing the destination on the package level, it writes
+ # on the move lines
+ move.move_line_ids.package_level_id.location_dest_id = scanned_location
move._action_done()
def cancel(self, package_level_id):
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 1150b68599..5e8956eb52 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1075,6 +1075,7 @@ def set_destination_all(
def _write_destination_on_lines(self, lines, location):
self._lock_lines(lines)
lines.location_dest_id = location
+ lines.package_level_id.location_dest_id = location
def unload_split(self, zone_location_id, picking_type_id):
"""Indicates that now the buffer must be treated line per line
From d6f887df77830d8386b505dcd2d2192f352d9c64 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Thu, 27 Aug 2020 11:31:23 +0200
Subject: [PATCH 340/940] shopfloor: ensure that confirmation email is sent
when moves are validated (#53)
---
shopfloor/models/stock_move.py | 13 ++
shopfloor/models/stock_picking.py | 13 ++
...ransfer_set_destination_package_or_line.py | 114 +++++++++++-------
shopfloor/tests/test_single_pack_transfer.py | 78 +++++++-----
.../test_zone_picking_set_line_destination.py | 106 +++++++++-------
.../tests/test_zone_picking_unload_all.py | 22 ++--
...est_zone_picking_unload_set_destination.py | 48 +++++---
7 files changed, 247 insertions(+), 147 deletions(-)
diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py
index f700f1bec7..c641389dcb 100644
--- a/shopfloor/models/stock_move.py
+++ b/shopfloor/models/stock_move.py
@@ -24,3 +24,16 @@ def split_other_move_lines(self, move_lines, intersection=False):
backorder_move._action_assign()
return backorder_move
return False
+
+ def _action_done(self, cancel_backorder=False):
+ # Overloaded to send the email when the last move of a picking is validated.
+ # The method 'stock.picking._send_confirmation_email' is called only from
+ # the 'stock.picking.action_done()' method but never when moves are
+ # validated partially through the current method.
+ moves = super()._action_done(cancel_backorder)
+ if not self.env.context.get("_action_done_from_picking"):
+ pickings = moves.picking_id
+ for picking in pickings:
+ if picking.state == "done":
+ picking._send_confirmation_email()
+ return moves
diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py
index a47b4d87ec..90e10fd131 100644
--- a/shopfloor/models/stock_picking.py
+++ b/shopfloor/models/stock_picking.py
@@ -42,3 +42,16 @@ def _create_backorder(self):
return self.browse()
else:
return super()._create_backorder()
+
+ def action_done(self):
+ self = self.with_context(_action_done_from_picking=True)
+ return super().action_done()
+
+ def _send_confirmation_email(self):
+ # Avoid sending the confirmation email twice (one when the
+ # 'picking.action_done()' is called, and one when the last move of this
+ # picking is validated through 'move._action_done()')
+ # We send the confirmation email
+ if self.env.context.get("_action_done_from_picking"):
+ return
+ super()._send_confirmation_email()
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
index b46855f270..f8fd7db29b 100644
--- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
@@ -1,3 +1,5 @@
+from unittest import mock
+
from .test_location_content_transfer_base import LocationContentTransferCommonCase
@@ -130,14 +132,18 @@ def test_set_destination_package_dest_location_to_confirm(self):
def test_set_destination_package_dest_location_ok(self):
"""Scanned destination location valid, moves set to done."""
package_level = self.picking1.package_level_ids[0]
- response = self.service.dispatch(
- "set_destination_package",
- params={
- "location_id": self.content_loc.id,
- "package_level_id": package_level.id,
- "barcode": self.dest_location.barcode,
- },
- )
+ with mock.patch.object(
+ type(self.picking1), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "set_destination_package",
+ params={
+ "location_id": self.content_loc.id,
+ "package_level_id": package_level.id,
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ send_confirmation_email.assert_called_once()
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
response,
@@ -301,15 +307,19 @@ def test_set_destination_line_partial_qty(self):
self.assertEqual(move_line_c.move_id.state, "done")
# Scan remaining qty (4/10)
remaining_move_line_c = move_product_c_splitted.move_line_ids
- response = self.service.dispatch(
- "set_destination_line",
- params={
- "location_id": self.content_loc.id,
- "move_line_id": remaining_move_line_c.id,
- "quantity": remaining_move_line_c.product_uom_qty,
- "barcode": self.dest_location.barcode,
- },
- )
+ with mock.patch.object(
+ type(self.picking2), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "set_destination_line",
+ params={
+ "location_id": self.content_loc.id,
+ "move_line_id": remaining_move_line_c.id,
+ "quantity": remaining_move_line_c.product_uom_qty,
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ send_confirmation_email.assert_not_called()
# Check move line data
self.assertEqual(remaining_move_line_c.move_id.product_uom_qty, 4)
self.assertEqual(remaining_move_line_c.product_uom_qty, 0)
@@ -329,20 +339,24 @@ def test_set_destination_line_partial_qty(self):
move_line_d = self.picking2.move_line_ids.filtered(
lambda m: m.product_id == self.product_d
)
- response = self.service.dispatch(
- "set_destination_line",
- params={
- "location_id": self.content_loc.id,
- "move_line_id": move_line_d.id,
- "quantity": move_line_d.product_uom_qty,
- "barcode": self.dest_location.barcode,
- },
- )
- self.assertEqual(move_line_d.move_id.product_uom_qty, 10)
- self.assertEqual(move_line_d.product_uom_qty, 0)
- self.assertEqual(move_line_d.qty_done, 10)
- self.assertEqual(move_line_d.state, "done")
- self.assertEqual(self.picking2.state, "done")
+ with mock.patch.object(
+ type(self.picking2), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "set_destination_line",
+ params={
+ "location_id": self.content_loc.id,
+ "move_line_id": move_line_d.id,
+ "quantity": move_line_d.product_uom_qty,
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ self.assertEqual(move_line_d.move_id.product_uom_qty, 10)
+ self.assertEqual(move_line_d.product_uom_qty, 0)
+ self.assertEqual(move_line_d.qty_done, 10)
+ self.assertEqual(move_line_d.state, "done")
+ self.assertEqual(self.picking2.state, "done")
+ send_confirmation_email.assert_called_once()
class LocationContentTransferSetDestinationXSpecialCase(
@@ -506,24 +520,32 @@ def test_set_destination_line_split_move(self):
remaining_move_lines = self.picking.move_line_ids_without_package.filtered(
lambda ml: ml.state == "assigned"
)
- for ml in remaining_move_lines:
+ with mock.patch.object(
+ type(self.picking), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ for ml in remaining_move_lines:
+ self.service.dispatch(
+ "set_destination_line",
+ params={
+ "location_id": self.content_loc.id,
+ "move_line_id": ml.id,
+ "quantity": ml.product_uom_qty,
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ self.assertEqual(self.picking.state, "assigned")
+ send_confirmation_email.assert_not_called()
+ package_level = self.picking.package_level_ids[0]
+ with mock.patch.object(
+ type(self.picking), "_send_confirmation_email"
+ ) as send_confirmation_email:
self.service.dispatch(
- "set_destination_line",
+ "set_destination_package",
params={
"location_id": self.content_loc.id,
- "move_line_id": ml.id,
- "quantity": ml.product_uom_qty,
+ "package_level_id": package_level.id,
"barcode": self.dest_location.barcode,
},
)
- self.assertEqual(self.picking.state, "assigned")
- package_level = self.picking.package_level_ids[0]
- self.service.dispatch(
- "set_destination_package",
- params={
- "location_id": self.content_loc.id,
- "package_level_id": package_level.id,
- "barcode": self.dest_location.barcode,
- },
- )
- self.assertEqual(self.picking.state, "done")
+ self.assertEqual(self.picking.state, "done")
+ send_confirmation_email.assert_called_once()
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index c98b8870cf..3397279830 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -1,3 +1,5 @@
+from unittest import mock
+
from odoo.tests.common import Form
from .common import CommonCase
@@ -397,13 +399,17 @@ def test_validate(self):
# now, call the service to proceed with validation of the
# movement
- response = self.service.dispatch(
- "validate",
- params={
- "package_level_id": package_level.id,
- "location_barcode": self.shelf2.barcode,
- },
- )
+ with mock.patch.object(
+ type(self.picking), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ },
+ )
+ send_confirmation_email.assert_called_once()
self.assert_response(
response,
@@ -465,13 +471,17 @@ def test_validate_completion_info(self):
# now, call the service to proceed with validation of the
# movement
- response = self.service.dispatch(
- "validate",
- params={
- "package_level_id": package_level.id,
- "location_barcode": self.shelf2.barcode,
- },
- )
+ with mock.patch.object(
+ type(self.picking), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ },
+ )
+ send_confirmation_email.assert_called_once()
self.assert_response(
response,
@@ -595,13 +605,17 @@ def test_validate_location_to_confirm(self):
# expected destination is 'shelf2', we'll scan shelf1 which must
# ask a confirmation to the user (it's still in the same picking type)
- response = self.service.dispatch(
- "validate",
- params={
- "package_level_id": package_level.id,
- "location_barcode": self.shelf1.barcode,
- },
- )
+ with mock.patch.object(
+ type(self.picking), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf1.barcode,
+ },
+ )
+ send_confirmation_email.assert_not_called()
message = self.service.actions_for("message").confirm_location_changed(
self.shelf2, self.shelf1
@@ -641,15 +655,19 @@ def test_validate_location_with_confirm(self):
# expected destination is 'shelf1', we'll scan shelf2 which must
# ask a confirmation to the user (it's still in the same picking type)
- response = self.service.dispatch(
- "validate",
- params={
- "package_level_id": package_level.id,
- "location_barcode": self.shelf2.barcode,
- # acknowledge the change of destination
- "confirmation": True,
- },
- )
+ with mock.patch.object(
+ type(self.picking), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ # acknowledge the change of destination
+ "confirmation": True,
+ },
+ )
+ send_confirmation_email.assert_called_once()
self.assert_response(
response,
diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py
index f1414d6823..da2f0e110f 100644
--- a/shopfloor/tests/test_zone_picking_set_line_destination.py
+++ b/shopfloor/tests/test_zone_picking_set_line_destination.py
@@ -1,3 +1,5 @@
+from unittest import mock
+
from .test_zone_picking_base import ZonePickingCommonCase
@@ -150,17 +152,21 @@ def test_set_destination_location_no_other_move_line_full_qty(self):
self.assertEqual(len(moves_before), 1)
self.assertEqual(len(moves_before.move_line_ids), 1)
move_line = moves_before.move_line_ids
- response = self.service.dispatch(
- "set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "move_line_id": move_line.id,
- "barcode": self.packing_location.barcode,
- "quantity": move_line.product_uom_qty,
- "confirmation": False,
- },
- )
+ with mock.patch.object(
+ type(self.picking1), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": False,
+ },
+ )
+ send_confirmation_email.assert_called_once()
# Check picking data
moves_after = self.picking1.move_lines
self.assertEqual(moves_before, moves_after)
@@ -200,17 +206,21 @@ def test_set_destination_location_no_other_move_line_partial_qty(self):
move_line = moves_before.move_line_ids
# we need a destination package if we want to scan a destination location
move_line.result_package_id = self.free_package
- response = self.service.dispatch(
- "set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "move_line_id": move_line.id,
- "barcode": barcode,
- "quantity": 6,
- "confirmation": False,
- },
- )
+ with mock.patch.object(
+ type(self.picking3), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": barcode,
+ "quantity": 6,
+ "confirmation": False,
+ },
+ )
+ send_confirmation_email.assert_not_called()
self.assert_response_set_line_destination(
response,
zone_location,
@@ -247,17 +257,21 @@ def test_set_destination_location_several_move_line_full_qty(self):
# we need a destination package if we want to scan a destination location
move_line.result_package_id = self.free_package
other_move_line = moves_before.move_line_ids[1]
- response = self.service.dispatch(
- "set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "move_line_id": move_line.id,
- "barcode": self.packing_location.barcode,
- "quantity": move_line.product_uom_qty, # 6 qty
- "confirmation": False,
- },
- )
+ with mock.patch.object(
+ type(self.picking4), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty, # 6 qty
+ "confirmation": False,
+ },
+ )
+ send_confirmation_email.assert_not_called()
# Check picking data (move has been split in two, 6 done and 4 remaining)
moves_after = self.picking4.move_lines
self.assertEqual(len(moves_after), 2)
@@ -306,17 +320,21 @@ def test_set_destination_location_several_move_line_partial_qty(self):
move_line = moves_before.move_line_ids[0]
# we need a destination package if we want to scan a destination location
move_line.result_package_id = self.free_package
- response = self.service.dispatch(
- "set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "move_line_id": move_line.id,
- "barcode": barcode,
- "quantity": 4, # 4/6 qty
- "confirmation": False,
- },
- )
+ with mock.patch.object(
+ type(self.picking4), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": barcode,
+ "quantity": 4, # 4/6 qty
+ "confirmation": False,
+ },
+ )
+ send_confirmation_email.assert_not_called()
self.assert_response_set_line_destination(
response,
zone_location,
diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py
index 86ebbc4ed9..d9ab4eb084 100644
--- a/shopfloor/tests/test_zone_picking_unload_all.py
+++ b/shopfloor/tests/test_zone_picking_unload_all.py
@@ -1,3 +1,5 @@
+from unittest import mock
+
from .test_zone_picking_base import ZonePickingCommonCase
@@ -159,14 +161,18 @@ def test_set_destination_all_ok(self):
another_package,
)
# set destination location for all lines in the buffer
- response = self.service.dispatch(
- "set_destination_all",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "barcode": self.packing_location.barcode,
- },
- )
+ with mock.patch.object(
+ type(self.picking5), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ send_confirmation_email.assert_called_once()
# check data
self.assertEqual(self.picking5.state, "done")
# buffer should be empty
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
index 38492df979..afd3a92465 100644
--- a/shopfloor/tests/test_zone_picking_unload_set_destination.py
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -1,3 +1,5 @@
+from unittest import mock
+
from .test_zone_picking_base import ZonePickingCommonCase
@@ -179,16 +181,20 @@ def test_unload_set_destination_ok_buffer_empty(self):
move_line.product_uom_qty,
self.free_package,
)
- response = self.service.dispatch(
- "unload_set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "package_id": self.free_package.id,
- "barcode": packing_sublocation.barcode,
- "confirmation": True,
- },
- )
+ with mock.patch.object(
+ type(self.picking1), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": self.free_package.id,
+ "barcode": packing_sublocation.barcode,
+ "confirmation": True,
+ },
+ )
+ send_confirmation_email.assert_called_once()
# check data
self.assertEqual(move_line.location_dest_id, packing_sublocation)
self.assertEqual(move_line.move_id.state, "done")
@@ -220,15 +226,19 @@ def test_unload_set_destination_ok_buffer_not_empty(self):
package_dest,
)
# process 1/2 buffer line
- response = self.service.dispatch(
- "unload_set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "package_id": self.free_package.id,
- "barcode": self.packing_location.barcode,
- },
- )
+ with mock.patch.object(
+ type(self.picking5), "_send_confirmation_email"
+ ) as send_confirmation_email:
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": self.free_package.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ send_confirmation_email.assert_not_called()
# check data
move_line = self.picking5.move_line_ids.filtered(
lambda l: l.result_package_id == self.free_package
From 2a8e1c697fd6f707088ec5e23530bcd4c1feb4eb Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 27 Aug 2020 14:48:12 +0200
Subject: [PATCH 341/940] bknd: map specific exceptions to odoo std exceptions
We were losing the possibility to display the txt msg of the error
because everything was wrapped by base_rest into InternalServerError.
---
shopfloor/services/service.py | 55 +++++++++++++++++++++++++++++------
1 file changed, 46 insertions(+), 9 deletions(-)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 877d561a45..35dbe6fcd5 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -28,6 +28,18 @@ def __init__(self, message, log_entry_url):
self.rest_json_info = {"log_entry_url": log_entry_url}
+class ShopfloorServiceUserErrorException(
+ ShopfloorServiceDispatchException, exceptions.UserError
+):
+ """User error wrapped exception."""
+
+
+class ShopfloorServiceValidationErrorException(
+ ShopfloorServiceDispatchException, exceptions.ValidationError
+):
+ """Validation error wrapped exception."""
+
+
class BaseShopfloorService(AbstractComponent):
"""Base class for REST services"""
@@ -55,20 +67,45 @@ def _db_logging_active(self):
def _dispatch_with_db_logging(self, method_name, _id=None, params=None):
try:
result = super().dispatch(method_name, _id=_id, params=params)
- except Exception as err:
- tb = traceback.format_exc()
- self.env.cr.rollback()
- with registry(self.env.cr.dbname).cursor() as cr:
- env = self.env(cr=cr)
- log_entry = self._log_call_in_db(env, request, _id, params, error=tb)
- log_entry_url = self._get_log_entry_url(log_entry)
- raise ShopfloorServiceDispatchException(str(err), log_entry_url) from err
-
+ except exceptions.UserError as orig_exception:
+ self._dispatch_exception(
+ ShopfloorServiceUserErrorException,
+ orig_exception,
+ _id=_id,
+ params=params,
+ )
+ except exceptions.ValidationError as orig_exception:
+ self._dispatch_exception(
+ ShopfloorServiceValidationErrorException,
+ orig_exception,
+ _id=_id,
+ params=params,
+ )
+ except Exception as orig_exception:
+ self._dispatch_exception(
+ ShopfloorServiceDispatchException,
+ orig_exception,
+ _id=_id,
+ params=params,
+ )
log_entry = self._log_call_in_db(self.env, request, _id, params, result=result)
log_entry_url = self._get_log_entry_url(log_entry)
result["log_entry_url"] = log_entry_url
return result
+ def _dispatch_exception(
+ self, exception_klass, orig_exception, _id=None, params=None
+ ):
+ tb = traceback.format_exc()
+ self.env.cr.rollback()
+ with registry(self.env.cr.dbname).cursor() as cr:
+ env = self.env(cr=cr)
+ log_entry = self._log_call_in_db(env, request, _id, params, error=tb)
+ log_entry_url = self._get_log_entry_url(log_entry)
+ # UserError and alike have `name` attribute to store the msg
+ exc_msg = getattr(orig_exception, "name", str(orig_exception))
+ raise exception_klass(exc_msg, log_entry_url) from orig_exception
+
def _get_log_entry_url(self, entry):
base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
url_params = {
From 32fa231a2e70a8d846c0c4bbd7deb338bb6b3df5 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 28 Aug 2020 14:19:04 +0200
Subject: [PATCH 342/940] Display product vendor code everywhere
---
shopfloor/actions/data.py | 4 ++++
shopfloor/services/schema.py | 1 +
shopfloor/tests/test_actions_data.py | 18 ++++++++++++++++++
3 files changed, 23 insertions(+)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index d8173033a8..86343d1013 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -206,6 +206,7 @@ def _product_parser(self):
"barcode",
("packaging_ids:packaging", self._product_packaging),
("uom_id:uom", self._simple_record_parser() + ["factor", "rounding"]),
+ ("seller_ids:supplier_code", self._product_supplier_code),
]
def _product_packaging(self, rec, field):
@@ -215,6 +216,9 @@ def _product_packaging(self, rec, field):
multi=True,
)
+ def _product_supplier_code(self, rec, field):
+ return rec.seller_ids[0].product_code or "" if rec.seller_ids else ""
+
def picking_batch(self, record, with_pickings=False, **kw):
parser = self._picking_batch_parser
if with_pickings:
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 16da390bb2..24d0480c8b 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -111,6 +111,7 @@ def product(self):
"display_name": {"type": "string", "nullable": False, "required": True},
"default_code": {"type": "string", "nullable": False, "required": True},
"barcode": {"type": "string", "nullable": True, "required": False},
+ "supplier_code": {"type": "string", "nullable": True, "required": False},
"packaging": self._schema_list_of(self.packaging()),
"uom": self._schema_dict_of(
self._simple_record(
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 0e9d12532e..23cf1c41e9 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -51,6 +51,21 @@ def setUpClassBaseData(cls):
(cls.move_a + cls.move_b + cls.move_c + cls.move_d).write({"priority": "1"})
cls.picking.action_assign()
+ cls.supplier = cls.env["res.partner"].sudo().create({"name": "Supplier"})
+ cls.vendor = (
+ cls.env["product.supplierinfo"]
+ .sudo()
+ .create(
+ {
+ "name": cls.supplier.id,
+ "price": 8.0,
+ "product_code": "VENDOR_CODE",
+ "product_id": cls.product_a.id,
+ "product_tmpl_id": cls.product_a.product_tmpl_id.id,
+ }
+ )
+ )
+
def assert_schema(self, schema, data):
validator = Validator(schema)
self.assertTrue(validator.validate(data), validator.errors)
@@ -80,6 +95,9 @@ def _expected_product(self, record, **kw):
"name": record.uom_id.name,
"rounding": record.uom_id.rounding,
},
+ "supplier_code": record.seller_ids[0].product_code
+ if record.seller_ids
+ else "",
}
data.update(kw)
return data
From 32daa0535f9d8ca07b7331e8e33915ad64ad1d91 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 2 Sep 2020 10:44:07 +0200
Subject: [PATCH 343/940] shopfloor.__manifest__ update references
---
shopfloor/__manifest__.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 7f89d4184a..6ae74a4daa 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -17,11 +17,11 @@
"base_rest",
"base_jsonify",
"auth_api_key",
- # https://github.com/OCA/stock-logistics-warehouse/pull/808
+ # OCA / stock-logistics-warehouse
"stock_picking_completion_info",
- # https://github.com/OCA/stock-logistics-workflow/pull/608
+ # OCA / stock-logistics-warehouse
"stock_quant_package_dimension",
- # https://github.com/OCA/stock-logistics-workflow/pull/607
+ # OCA / stock-logistics-warehouse
"stock_quant_package_product_packaging",
# TODO: used for manuf info on prod detail.
# This must be an optional dep
From 75b08e0ab17b01a628d3db9517fe698c1ab49e2c Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 2 Sep 2020 10:50:50 +0200
Subject: [PATCH 344/940] Packaging data: rely on packaging type
Packaging type gives more information on the packaging
and its name is translatable.
---
shopfloor/__manifest__.py | 2 ++
shopfloor/actions/data.py | 7 ++++++-
shopfloor/services/schema.py | 1 +
shopfloor/tests/test_actions_data.py | 14 ++++++++++++--
4 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 6ae74a4daa..4412ed12e6 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -35,6 +35,8 @@
# TODO: used for picking.carrier_id detail info.
# This must be an optional dep
"delivery",
+ # OCA / product-attribute
+ "product_packaging_type",
],
"data": [
"data/ir_config_parameter_data.xml",
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 86343d1013..b6f687999d 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -107,7 +107,12 @@ def packaging_list(self, record, **kw):
@property
def _packaging_parser(self):
- return self._simple_record_parser() + ["qty"]
+ return [
+ "id",
+ ("packaging_type_id:name", lambda rec, fname: rec.packaging_type_id.name),
+ ("packaging_type_id:code", lambda rec, fname: rec.packaging_type_id.code),
+ "qty",
+ ]
def lot(self, record, **kw):
return self._jsonify(record, self._lot_parser, **kw)
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 24d0480c8b..d1d424c3a8 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -151,6 +151,7 @@ def packaging(self):
return {
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
+ "code": {"type": "string", "nullable": True, "required": True},
"qty": {"type": "float", "required": True},
}
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 23cf1c41e9..d0d61adaef 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -24,7 +24,16 @@ def setUpClassVars(cls):
@classmethod
def setUpClassBaseData(cls):
super().setUpClassBaseData()
- cls.packaging = cls.env["product.packaging"].sudo().create({"name": "Pallet"})
+ cls.packaging_type = (
+ cls.env["product.packaging.type"]
+ .sudo()
+ .create({"name": "Transport Box", "code": "TB", "sequence": 0})
+ )
+ cls.packaging = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create({"name": "Pallet", "packaging_type_id": cls.packaging_type.id})
+ )
cls.product_b.tracking = "lot"
cls.product_c.tracking = "lot"
cls.picking = cls._create_picking(
@@ -105,7 +114,8 @@ def _expected_product(self, record, **kw):
def _expected_packaging(self, record, **kw):
data = {
"id": record.id,
- "name": record.name,
+ "name": record.packaging_type_id.name,
+ "code": record.packaging_type_id.code,
"qty": record.qty,
}
data.update(kw)
From cd9401e65da29a6245c3d74bfb6d56ac75ae5aa6 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 28 Aug 2020 16:15:14 +0200
Subject: [PATCH 345/940] Add active field on shopfloor profile
It already exists on the menus.
---
shopfloor/models/shopfloor_profile.py | 1 +
shopfloor/views/shopfloor_profile_views.xml | 13 +++++++++++++
2 files changed, 14 insertions(+)
diff --git a/shopfloor/models/shopfloor_profile.py b/shopfloor/models/shopfloor_profile.py
index ec9e66c792..eb6b6e1309 100644
--- a/shopfloor/models/shopfloor_profile.py
+++ b/shopfloor/models/shopfloor_profile.py
@@ -14,6 +14,7 @@ class ShopfloorProfile(models.Model):
menu_ids = fields.Many2many(
"shopfloor.menu", string="Menus", help="Menus visible for this profile"
)
+ active = fields.Boolean(default=True)
@api.model
def _default_warehouse_id(self):
diff --git a/shopfloor/views/shopfloor_profile_views.xml b/shopfloor/views/shopfloor_profile_views.xml
index 785aa6231d..7a95ac5506 100644
--- a/shopfloor/views/shopfloor_profile_views.xml
+++ b/shopfloor/views/shopfloor_profile_views.xml
@@ -16,12 +16,19 @@
+
+
@@ -35,6 +42,12 @@
+
+
From decb9ce9a6433b00c57cd467c6d70aab72dab392 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 31 Aug 2020 12:11:07 +0200
Subject: [PATCH 346/940] bknd: fix vendor code by variant
---
shopfloor/actions/data.py | 5 +++-
shopfloor/tests/test_actions_data.py | 37 ++++++++++++++++++++++++----
2 files changed, 36 insertions(+), 6 deletions(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index b6f687999d..0ad4f88d10 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -222,7 +222,10 @@ def _product_packaging(self, rec, field):
)
def _product_supplier_code(self, rec, field):
- return rec.seller_ids[0].product_code or "" if rec.seller_ids else ""
+ supplier_info = fields.first(
+ rec.seller_ids.filtered(lambda x: x.product_id == rec)
+ )
+ return supplier_info.product_code if supplier_info else ""
def picking_batch(self, record, with_pickings=False, **kw):
parser = self._picking_batch_parser
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index d0d61adaef..43bff8b1e3 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -61,19 +61,44 @@ def setUpClassBaseData(cls):
cls.picking.action_assign()
cls.supplier = cls.env["res.partner"].sudo().create({"name": "Supplier"})
- cls.vendor = (
+ cls.product_a_vendor = (
cls.env["product.supplierinfo"]
.sudo()
.create(
{
"name": cls.supplier.id,
"price": 8.0,
- "product_code": "VENDOR_CODE",
+ "product_code": "VENDOR_CODE_A",
"product_id": cls.product_a.id,
"product_tmpl_id": cls.product_a.product_tmpl_id.id,
}
)
)
+ cls.product_a_variant = cls.product_a.copy(
+ {
+ "name": "Product A variant 1",
+ "type": "product",
+ "default_code": "A-VARIANT",
+ "barcode": "A-VARIANT",
+ }
+ )
+ # create another supplier info w/ lower sequence
+ cls.product_a_vendor = (
+ cls.env["product.supplierinfo"]
+ .sudo()
+ .create(
+ {
+ "name": cls.supplier.id,
+ "price": 12.0,
+ "product_code": "VENDOR_CODE_VARIANT",
+ "product_id": cls.product_a_variant.id,
+ "product_tmpl_id": cls.product_a.product_tmpl_id.id,
+ "sequence": 0,
+ }
+ )
+ )
+ cls.product_a_variant.flush()
+ cls.product_a_vendor.flush()
def assert_schema(self, schema, data):
validator = Validator(schema)
@@ -104,13 +129,15 @@ def _expected_product(self, record, **kw):
"name": record.uom_id.name,
"rounding": record.uom_id.rounding,
},
- "supplier_code": record.seller_ids[0].product_code
- if record.seller_ids
- else "",
+ "supplier_code": self._expected_supplier_code(record),
}
data.update(kw)
return data
+ def _expected_supplier_code(self, product):
+ supplier_info = product.seller_ids.filtered(lambda x: x.product_id == product)
+ return supplier_info[0].product_code if supplier_info else ""
+
def _expected_packaging(self, record, **kw):
data = {
"id": record.id,
From e415fdf6eb864c566ed2ce4d4e79e044b5a756d0 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Thu, 27 Aug 2020 14:55:50 +0200
Subject: [PATCH 347/940] zone picking: fix check against destination location
when unloading
---
shopfloor/services/zone_picking.py | 25 +++++++---
.../tests/test_zone_picking_unload_all.py | 50 ++++++++++++++++---
...est_zone_picking_unload_set_destination.py | 22 ++++++--
3 files changed, 77 insertions(+), 20 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 5e8956eb52..16b850c0a9 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1048,19 +1048,23 @@ def set_destination_all(
zone_location, picking_type, buffer_lines, message=error
)
# check if the destination location is not the expected one
- if location != picking_type.default_location_dest_id:
+ # - OK if the scanned destination is a child of the current
+ # destination set on buffer lines
+ # - To confirm if the scanned destination is not a child of the
+ # current destination set on buffer lines
+ if not location.is_sublocation_of(buffer_lines.location_dest_id):
if not confirmation:
return self._response_for_unload_all(
zone_location,
picking_type,
buffer_lines,
message=self.msg_store.confirm_location_changed(
- picking_type.default_location_dest_id, location
+ first(buffer_lines.location_dest_id), location
),
confirmation_required=True,
)
- # the scanned location is still valid, use it
- self._write_destination_on_lines(buffer_lines, location)
+ # the scanned location is still valid, use it
+ self._write_destination_on_lines(buffer_lines, location)
# set lines to done + refresh buffer lines (should be empty)
moves = buffer_lines.mapped("move_id")
moves.with_context(_sf_no_backorder=True)._action_done()
@@ -1242,19 +1246,24 @@ def unload_set_destination(
first(buffer_lines),
message=self.msg_store.dest_location_not_allowed(),
)
- if location != picking_type.default_location_dest_id:
+ # check if the destination location is not the expected one
+ # - OK if the scanned destination is a child of the current
+ # destination set on buffer lines
+ # - To confirm if the scanned destination is not a child of the
+ # current destination set on buffer lines
+ if not location.is_sublocation_of(buffer_lines.location_dest_id):
if not confirmation:
return self._response_for_unload_set_destination(
zone_location,
picking_type,
first(buffer_lines),
message=self.msg_store.confirm_location_changed(
- picking_type.default_location_dest_id, location
+ first(buffer_lines.location_dest_id), location
),
confirmation_required=True,
)
- # the scanned location is valid, use it
- self._write_destination_on_lines(buffer_lines, location)
+ # the scanned location is valid, use it
+ self._write_destination_on_lines(buffer_lines, location)
# set lines to done + refresh buffer lines (should be empty)
moves = buffer_lines.mapped("move_id")
moves.with_context(_sf_no_backorder=True)._action_done()
diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py
index d9ab4eb084..7a37d01241 100644
--- a/shopfloor/tests/test_zone_picking_unload_all.py
+++ b/shopfloor/tests/test_zone_picking_unload_all.py
@@ -89,14 +89,25 @@ def test_set_destination_all_confirm_destination(self):
another_package = self.env["stock.quant.package"].create(
{"name": "ANOTHER_PACKAGE"}
)
- packing_sublocation = (
+ packing_sublocation1 = (
self.env["stock.location"]
.sudo()
.create(
{
- "name": "Packing sublocation",
+ "name": "Packing sublocation-1",
"location_id": self.packing_location.id,
- "barcode": "PACKING_SUBLOCATION",
+ "barcode": "PACKING_SUBLOCATION_1",
+ }
+ )
+ )
+ packing_sublocation2 = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Packing sublocation-2",
+ "location_id": self.packing_location.id,
+ "barcode": "PACKING_SUBLOCATION_2",
}
)
)
@@ -115,16 +126,20 @@ def test_set_destination_all_confirm_destination(self):
move_line2.product_uom_qty,
another_package,
)
- # set destination location for all lines in the buffer
+ # set an allowed destination location (inside the picking type default
+ # destination location) for all lines in the buffer with a non-expected
+ # one, meaning a destination which is not a child of the current buffer
+ # lines destination
+ (move_line1 | move_line2).location_dest_id = packing_sublocation1
response = self.service.dispatch(
"set_destination_all",
params={
"zone_location_id": zone_location.id,
"picking_type_id": picking_type.id,
- "barcode": packing_sublocation.barcode,
+ "barcode": packing_sublocation2.barcode,
},
)
- # check response
+ # check response: this destination needs the user confirmation
buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
self.assert_response_unload_all(
response,
@@ -132,10 +147,31 @@ def test_set_destination_all_confirm_destination(self):
picking_type,
buffer_lines,
message=self.service.msg_store.confirm_location_changed(
- picking_type.default_location_dest_id, packing_sublocation,
+ packing_sublocation1, packing_sublocation2,
),
confirmation_required=True,
)
+ # set an allowed destination location (inside the picking type default
+ # destination location) for all lines in the buffer with an expected one
+ # meaning a destination which is a child of the current buffer lines
+ # destination
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": packing_sublocation1.barcode,
+ },
+ )
+ # check response: OK
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.buffer_complete(),
+ )
def test_set_destination_all_ok(self):
zone_location = self.zone_location
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
index afd3a92465..2024d16524 100644
--- a/shopfloor/tests/test_zone_picking_unload_set_destination.py
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -119,14 +119,25 @@ def test_unload_set_destination_confirm_location(self):
zone_location = self.zone_location
picking_type = self.picking1.picking_type_id
move_line = self.picking1.move_line_ids
- packing_sublocation = (
+ packing_sublocation1 = (
self.env["stock.location"]
.sudo()
.create(
{
- "name": "Packing sublocation",
+ "name": "Packing sublocation-1",
"location_id": self.packing_location.id,
- "barcode": "PACKING_SUBLOCATION",
+ "barcode": "PACKING_SUBLOCATIO_1",
+ }
+ )
+ )
+ packing_sublocation2 = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Packing sublocation-2",
+ "location_id": self.packing_location.id,
+ "barcode": "PACKING_SUBLOCATIO_2",
}
)
)
@@ -138,13 +149,14 @@ def test_unload_set_destination_confirm_location(self):
move_line.product_uom_qty,
self.free_package,
)
+ move_line.location_dest_id = packing_sublocation1
response = self.service.dispatch(
"unload_set_destination",
params={
"zone_location_id": zone_location.id,
"picking_type_id": picking_type.id,
"package_id": self.free_package.id,
- "barcode": packing_sublocation.barcode,
+ "barcode": packing_sublocation2.barcode,
},
)
self.assert_response_unload_set_destination(
@@ -153,7 +165,7 @@ def test_unload_set_destination_confirm_location(self):
picking_type,
move_line,
message=self.service.msg_store.confirm_location_changed(
- picking_type.default_location_dest_id, packing_sublocation
+ packing_sublocation1, packing_sublocation2
),
confirmation_required=True,
)
From bb8e2a3ba5fb38aff563eb0766ef7498b2428e63 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Thu, 27 Aug 2020 16:12:47 +0200
Subject: [PATCH 348/940] zone picking: if the buffer is empty when we want to
set the destination, get back on 'select_line'
---
shopfloor/services/zone_picking.py | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 16b850c0a9..3098e1b9c5 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1224,7 +1224,10 @@ def unload_set_destination(
if not picking_type.exists():
return self._response_for_start(message=self.msg_store.record_not_found())
package = self.env["stock.quant.package"].browse(package_id)
- if not package.exists():
+ buffer_lines = self._find_buffer_move_lines(
+ zone_location, picking_type, dest_package=package
+ )
+ if not package.exists() or not buffer_lines:
move_lines = self._find_location_move_lines(zone_location, picking_type)
return self._response_for_select_line(
zone_location,
@@ -1232,10 +1235,6 @@ def unload_set_destination(
move_lines,
message=self.msg_store.record_not_found(),
)
-
- buffer_lines = self._find_buffer_move_lines(
- zone_location, picking_type, dest_package=package
- )
search = self.actions_for("search")
location = search.location_from_scan(barcode)
if location:
From 9d748669403dca7a8202ba79f0197624348c8528 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 28 Aug 2020 14:05:05 +0200
Subject: [PATCH 349/940] Add check of scanned dest. location vs move's
destination
When we scan a location, generally, we have these rules:
* If the scanned location is not a child (or =) of the picking type's
default destination, reject it
* If the scanned location is not a child (or =) of of the current move
line's destination, ask for a confirmation
However, we have no check on the move's destination location.
Example
-------
Locations:
Packing Zone
\- PACK-1
\- PACK-2
Our transfer has:
* Picking type destination location: Packing Zone
* Move destination location: Packing Zone/PACK-1
* Move line destination location: Packing Zone/PACK-1
Current behavior:
* If we scan "PACK-1", the location is accepted directly
* If we scan "PACK-2", the app asks for a confirmation
If the user confirms PACK-2, we'll have inconsistencies with such
result:
* Move destination location: Packing Zone/PACK-1
* Move line destination location: Packing Zone/PACK-2
Also, the next move in the chain expects to receive goods in "Packing
Zone/PACK-1", so we break the chain.
Expected behavior after this commit:
* If we scan "PACK-1", the location is accepted directly
* If we scan "PACK-2", the location is rejected
Considerations
--------------
This issue was detected in the location content transfer, but it may
happen in the other scenarios, so all of them should be fixed.
Except for "Single Pack Transfer" which was already handling this in a
different way, by updating the move's destination location. I don't
think it is correct, as I doubt the next move in the chain will be
correct then. But I don't want to break a use case that could be used,
so not changing it for now.
In Zone Picking, I simplified a bit the checks on the location: by
checking *first* if we are in the picking type (and in the move
desination now), we can return early and avoid repeating the condition
on the next check.
---
shopfloor/models/stock_location.py | 11 +++-
shopfloor/services/cluster_picking.py | 5 ++
.../services/location_content_transfer.py | 8 +++
shopfloor/services/single_pack_transfer.py | 2 +
shopfloor/services/zone_picking.py | 66 +++++++++++--------
.../tests/test_cluster_picking_unload.py | 51 ++++++++++++++
...on_content_transfer_set_destination_all.py | 18 +++++
...ransfer_set_destination_package_or_line.py | 44 +++++++++++++
shopfloor/tests/test_zone_picking_base.py | 22 +++++++
.../test_zone_picking_set_line_destination.py | 28 ++++++++
...est_zone_picking_unload_set_destination.py | 30 +++++++++
11 files changed, 253 insertions(+), 32 deletions(-)
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
index db4672b573..eb8f6909ac 100644
--- a/shopfloor/models/stock_location.py
+++ b/shopfloor/models/stock_location.py
@@ -20,12 +20,17 @@ class StockLocation(models.Model):
comodel_name="stock.move.line", compute="_compute_reserved_move_lines",
)
- def is_sublocation_of(self, others):
- """Return True if self is a sublocation of at least one other"""
+ def is_sublocation_of(self, others, func=any):
+ """Return True if self is a sublocation of others (or equal)
+
+ By default, it return True if any other is a parent or equal.
+ ``all`` can be passed to ``func`` to require all the other locations
+ to be parent or equal to be True.
+ """
self.ensure_one()
# Efficient way to verify that the current location is
# below one of the other location without using SQL.
- return any(self.parent_path.startswith(other.parent_path) for other in others)
+ return func(self.parent_path.startswith(other.parent_path) for other in others)
def _get_reserved_move_lines(self):
return self.env["stock.move.line"].search(
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index a1867cb7db..9d45a70b31 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -941,6 +941,8 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
)
if not scanned_location.is_sublocation_of(
picking_type.default_location_dest_id
+ ) or not scanned_location.is_sublocation_of(
+ lines.mapped("move_id.location_dest_id"), func=all
):
return self._response_for_unload_all(
batch, message=self.msg_store.dest_location_not_allowed()
@@ -1092,8 +1094,11 @@ def _unload_scan_destination_lines(
return self._response_for_unload_set_destination(
batch, package, message=self.msg_store.no_location_found()
)
+
if not scanned_location.is_sublocation_of(
picking_type.default_location_dest_id
+ ) or not scanned_location.is_sublocation_of(
+ lines.mapped("move_id.location_dest_id"), func=all
):
return self._response_for_unload_set_destination(
batch, package, message=self.msg_store.dest_location_not_allowed()
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 0222626737..31887876a1 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -366,6 +366,8 @@ def set_destination_all(self, location_id, barcode, confirmation=False):
if not scanned_location.is_sublocation_of(
self.picking_types.mapped("default_location_dest_id")
+ ) or not scanned_location.is_sublocation_of(
+ move_lines.mapped("move_id.location_dest_id"), func=all
):
return self._response_for_scan_destination_all(
pickings, message=self.msg_store.dest_location_not_allowed()
@@ -550,6 +552,10 @@ def set_destination_package(
)
if not scanned_location.is_sublocation_of(
package_level.picking_id.picking_type_id.default_location_dest_id
+ ) or not scanned_location.is_sublocation_of(
+ # beware, package_level.move_id is not always set
+ package_level.move_line_ids.move_id.location_dest_id,
+ func=all,
):
return self._response_for_scan_destination(
location,
@@ -617,6 +623,8 @@ def set_destination_line(
)
if not scanned_location.is_sublocation_of(
move_line.picking_id.picking_type_id.default_location_dest_id
+ ) or not scanned_location.is_sublocation_of(
+ move_line.move_id.location_dest_id, func=all
):
return self._response_for_scan_destination(
location, move_line, message=self.msg_store.dest_location_not_allowed()
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 308a420b94..f437449b05 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -175,6 +175,8 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
if confirmation:
# If the destination of the move would be incoherent
# (move line outside of it), we change the moves' destination
+ # TODO in other scenarios, we forbid this. Check if we want
+ # to forbid it as well.
if not scanned_location.is_sublocation_of(move.location_dest_id):
move.location_dest_id = scanned_location.id
else:
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 3098e1b9c5..ad277ca0da 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -115,7 +115,7 @@ def _response_for_set_line_destination(
data = self._data_for_move_line(zone_location, picking_type, move_line)
data["confirmation_required"] = confirmation_required
return self._response(
- next_state="set_line_destination", data=data, message=message,
+ next_state="set_line_destination", data=data, message=message
)
def _response_for_zero_check(
@@ -148,7 +148,7 @@ def _response_for_unload_all(
message = self.msg_store.need_confirmation()
data = self._data_for_move_lines(zone_location, picking_type, move_lines)
data["confirmation_required"] = confirmation_required
- return self._response(next_state="unload_all", data=data, message=message,)
+ return self._response(next_state="unload_all", data=data, message=message)
def _response_for_unload_single(
self, zone_location, picking_type, move_line, message=None, popup=None
@@ -176,7 +176,7 @@ def _response_for_unload_set_destination(
data = self._data_for_move_line(zone_location, picking_type, move_line)
data["confirmation_required"] = confirmation_required
return self._response(
- next_state="unload_set_destination", data=data, message=message,
+ next_state="unload_set_destination", data=data, message=message
)
def _data_for_select_picking_type(self, zone_location, picking_types):
@@ -455,7 +455,7 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit
if not move_line:
response = self.list_move_lines(zone_location.id, picking_type.id)
return self._response(
- base_response=response, message=self.msg_store.package_not_found(),
+ base_response=response, message=self.msg_store.package_not_found()
)
product = search.product_from_scan(barcode)
if product:
@@ -465,7 +465,7 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit
if not move_line:
response = self.list_move_lines(zone_location.id, picking_type.id)
return self._response(
- base_response=response, message=self.msg_store.product_not_found(),
+ base_response=response, message=self.msg_store.product_not_found()
)
lot = search.lot_from_scan(barcode)
if lot:
@@ -473,13 +473,13 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit
if not move_line:
response = self.list_move_lines(zone_location.id, picking_type.id)
return self._response(
- base_response=response, message=self.msg_store.lot_not_found(),
+ base_response=response, message=self.msg_store.lot_not_found()
)
# barcode not found, get back on 'select_line' screen
if not move_line:
response = self.list_move_lines(zone_location.id, picking_type.id)
return self._response(
- base_response=response, message=self.msg_store.barcode_not_found(),
+ base_response=response, message=self.msg_store.barcode_not_found()
)
return self._response_for_set_line_destination(
zone_location, picking_type, move_line
@@ -490,36 +490,40 @@ def _set_destination_location(
):
location_changed = False
response = None
+
+ # A valid location is a sub-location of the original destination, or a
+ # any sub-location of the picking type's default destination location
+ # if `confirmation is True
# Ask confirmation to the user if the scanned location is not in the
# expected ones but is valid (in picking type's default destination)
- if not location.is_sublocation_of(move_line.location_dest_id) and (
- not confirmation
- and location.is_sublocation_of(picking_type.default_location_dest_id)
+ if not location.is_sublocation_of(
+ picking_type.default_location_dest_id
+ ) or not location.is_sublocation_of(
+ move_line.move_id.location_dest_id, func=all
):
response = self._response_for_set_line_destination(
zone_location,
picking_type,
move_line,
- message=self.msg_store.confirm_location_changed(
- move_line.location_dest_id, location
- ),
- confirmation_required=True,
+ message=self.msg_store.dest_location_not_allowed(),
)
return (location_changed, response)
- # A valid location is a sub-location of the original destination, or a
- # sub-location of the picking type's default destination location if
- # `confirmation is True
- if not location.is_sublocation_of(move_line.location_dest_id) and (
- confirmation
- and not location.is_sublocation_of(picking_type.default_location_dest_id)
+
+ if (
+ not location.is_sublocation_of(move_line.location_dest_id)
+ and not confirmation
):
response = self._response_for_set_line_destination(
zone_location,
picking_type,
move_line,
- message=self.msg_store.dest_location_not_allowed(),
+ message=self.msg_store.confirm_location_changed(
+ move_line.location_dest_id, location
+ ),
+ confirmation_required=True,
)
return (location_changed, response)
+
# If no destination package
if not move_line.result_package_id:
response = self._response_for_set_line_destination(
@@ -744,7 +748,7 @@ def set_destination(
# Process the next line
response = self.list_move_lines(zone_location.id, picking_type.id)
- return self._response(base_response=response, message=message,)
+ return self._response(base_response=response, message=message)
def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero):
"""Confirm or not if the source location of a move has zero qty
@@ -984,7 +988,7 @@ def prepare_unload(self, zone_location_id, picking_type_id):
return self._response_for_unload_single(
zone_location, picking_type, first(move_lines)
)
- move_lines = self._find_location_move_lines(zone_location, picking_type,)
+ move_lines = self._find_location_move_lines(zone_location, picking_type)
return self._response_for_select_line(zone_location, picking_type, move_lines)
def _set_destination_all_response(
@@ -992,12 +996,12 @@ def _set_destination_all_response(
):
if buffer_lines:
return self._response_for_unload_all(
- zone_location, picking_type, buffer_lines, message=message,
+ zone_location, picking_type, buffer_lines, message=message
)
move_lines = self._find_location_move_lines(zone_location, picking_type)
if move_lines:
return self._response_for_select_line(
- zone_location, picking_type, move_lines, message=message,
+ zone_location, picking_type, move_lines, message=message
)
return self._response_for_start(message=message)
@@ -1073,7 +1077,7 @@ def set_destination_all(
else:
message = self.msg_store.no_location_found()
return self._set_destination_all_response(
- zone_location, picking_type, buffer_lines, message=message,
+ zone_location, picking_type, buffer_lines, message=message
)
def _write_destination_on_lines(self, lines, location):
@@ -1104,7 +1108,7 @@ def unload_split(self, zone_location_id, picking_type_id):
# more than one remaining move line in the buffer
if len(buffer_lines) > 1:
return self._response_for_unload_single(
- zone_location, picking_type, first(buffer_lines),
+ zone_location, picking_type, first(buffer_lines)
)
# only one move line to process in the buffer
elif len(buffer_lines) == 1:
@@ -1238,7 +1242,11 @@ def unload_set_destination(
search = self.actions_for("search")
location = search.location_from_scan(barcode)
if location:
- if not location.is_sublocation_of(picking_type.default_location_dest_id):
+ if not location.is_sublocation_of(
+ picking_type.default_location_dest_id
+ ) or not location.is_sublocation_of(
+ buffer_lines.move_id.location_dest_id, func=all
+ ):
return self._response_for_unload_set_destination(
zone_location,
picking_type,
@@ -1269,7 +1277,7 @@ def unload_set_destination(
buffer_lines = self._find_buffer_move_lines(zone_location, picking_type)
if buffer_lines:
return self._response_for_unload_single(
- zone_location, picking_type, first(buffer_lines),
+ zone_location, picking_type, first(buffer_lines)
)
move_lines = self._find_location_move_lines(zone_location, picking_type)
if move_lines:
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 6528eafe12..1df4f0e293 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -285,6 +285,33 @@ def test_set_destination_all_error_location_invalid(self):
message={"message_type": "error", "body": "You cannot place it here"},
)
+ def test_set_destination_all_error_location_move_invalid(self):
+ """Endpoint called with a barcode for an invalid location
+
+ It is invalid when the location is not the destination location or
+ sublocation of move line's move
+ """
+ move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ self._set_dest_package_and_done(move_lines, self.bin1)
+ move_lines.write({"location_dest_id": self.packing_a_location.id})
+ move_lines[0].move_id.location_dest_id = self.packing_a_location
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_b_location.barcode,
+ },
+ )
+ location = move_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location)
+ self.assert_response(
+ response,
+ next_state="unload_all",
+ data=data,
+ message=self.service.msg_store.dest_location_not_allowed(),
+ )
+
def test_set_destination_all_need_confirmation(self):
"""Endpoint called with a barcode for another (valid) location"""
move_lines = self.batch.mapped("picking_ids.move_line_ids")
@@ -651,6 +678,30 @@ def test_unload_scan_destination_error_location_invalid(self):
message={"message_type": "error", "body": "You cannot place it here"},
)
+ def test_unload_scan_destination_error_location_move_invalid(self):
+ """Endpoint called with a barcode for an invalid location
+
+ It is invalid when the location is not the destination location or
+ sublocation of the move line's move
+ """
+ self.bin1_lines[0].move_id.location_dest_id = self.packing_a_location
+ response = self.service.dispatch(
+ "unload_scan_destination",
+ params={
+ "picking_batch_id": self.batch.id,
+ "package_id": self.bin1.id,
+ "barcode": self.packing_b_location.barcode,
+ },
+ )
+ location = self.bin1_lines[0].location_dest_id
+ data = self._data_for_batch(self.batch, location, pack=self.bin1)
+ self.assert_response(
+ response,
+ next_state="unload_set_destination",
+ data=data,
+ message=self.service.msg_store.dest_location_not_allowed(),
+ )
+
def test_unload_scan_destination_need_confirmation(self):
"""Endpoint called with a barcode for another (valid) location"""
response = self.service.dispatch(
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py
index fb2c21b876..fc33a4d742 100644
--- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py
@@ -149,6 +149,24 @@ def test_set_destination_all_dest_location_invalid(self):
message=self.service.msg_store.dest_location_not_allowed(),
)
+ def test_set_destination_all_dest_location_move_invalid(self):
+ """The scanned destination location is not in the move's dest location"""
+ # if we have at least one move which does not match the scanned location
+ # we forbid the action
+ self.pickings.move_lines[0].location_dest_id = self.shelf1
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "location_id": self.content_loc.id,
+ "barcode": self.shelf2.barcode,
+ },
+ )
+ self.assert_response_scan_destination_all(
+ response,
+ self.pickings,
+ message=self.service.msg_store.dest_location_not_allowed(),
+ )
+
def test_go_to_single(self):
"""User used to 'split by lines' button to process line per line"""
response = self.service.dispatch(
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
index f8fd7db29b..92e58bc17a 100644
--- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
@@ -111,6 +111,28 @@ def test_set_destination_package_dest_location_nok(self):
message=self.service.msg_store.dest_location_not_allowed(),
)
+ def test_set_destination_package_dest_location_move_nok(self):
+ """Scanned destination location not valid (different as move)"""
+ package_level = self.picking1.package_level_ids[0]
+ # if the move related to the package level has a destination
+ # location not a parent or equal to the scanned location,
+ # refuse the action
+ move = package_level.move_line_ids.move_id
+ move.location_dest_id = self.shelf1
+ response = self.service.dispatch(
+ "set_destination_package",
+ params={
+ "location_id": self.content_loc.id,
+ "package_level_id": package_level.id,
+ "barcode": self.shelf2.barcode,
+ },
+ )
+ self.assert_response_scan_destination(
+ response,
+ package_level,
+ message=self.service.msg_store.dest_location_not_allowed(),
+ )
+
def test_set_destination_package_dest_location_to_confirm(self):
"""Scanned destination location valid, but need a confirmation."""
package_level = self.picking1.package_level_ids[0]
@@ -220,6 +242,28 @@ def test_set_destination_line_dest_location_nok(self):
message=self.service.msg_store.dest_location_not_allowed(),
)
+ def test_set_destination_line_dest_location_move_nok(self):
+ """Scanned destination location not valid (different as move)"""
+ move_line = self.picking2.move_line_ids[0]
+ # if the move related to the move line has a destination
+ # location not a parent or equal to the scanned location,
+ # refuse the action
+ move_line.move_id.location_dest_id = self.shelf1
+ response = self.service.dispatch(
+ "set_destination_line",
+ params={
+ "location_id": self.content_loc.id,
+ "move_line_id": move_line.id,
+ "quantity": move_line.product_uom_qty,
+ "barcode": self.shelf2.barcode,
+ },
+ )
+ self.assert_response_scan_destination(
+ response,
+ move_line,
+ message=self.service.msg_store.dest_location_not_allowed(),
+ )
+
def test_set_destination_line_dest_location_to_confirm(self):
"""Scanned destination location valid, but need a confirmation."""
move_line = self.picking2.move_line_ids[0]
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 097e7e0e15..05c019c4bc 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -71,6 +71,28 @@ def setUpClassBaseData(cls, *args, **kwargs):
}
)
)
+ cls.packing_sublocation_a = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Packing Sublocation A",
+ "location_id": cls.packing_location.id,
+ "barcode": "PACKING_SUBLOCATION_A",
+ }
+ )
+ )
+ cls.packing_sublocation_b = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Packing Sublocation B",
+ "location_id": cls.packing_location.id,
+ "barcode": "PACKING_SUBLOCATION_B",
+ }
+ )
+ )
cls.product_e = (
cls.env["product.product"]
.sudo()
diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py
index da2f0e110f..f163958781 100644
--- a/shopfloor/tests/test_zone_picking_set_line_destination.py
+++ b/shopfloor/tests/test_zone_picking_set_line_destination.py
@@ -131,6 +131,34 @@ def test_set_destination_location_confirm(self):
message=self.service.msg_store.confirm_pack_moved(),
)
+ def test_set_destination_location_move_invalid_location(self):
+ # Confirm the destination with a wrong destination, outside of move's
+ # move line (should not happen)
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ move_line.location_dest_id = self.packing_sublocation_a
+ move_line.move_id.location_dest_id = self.packing_sublocation_a
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_sublocation_b.barcode,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": True,
+ },
+ )
+ # Check response
+ self.assert_response_set_line_destination(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.dest_location_not_allowed(),
+ )
+
def test_set_destination_location_no_other_move_line_full_qty(self):
"""Scanned barcode is the destination location.
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
index 2024d16524..be32d2295a 100644
--- a/shopfloor/tests/test_zone_picking_unload_set_destination.py
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -115,6 +115,36 @@ def test_unload_set_destination_location_not_allowed(self):
message=self.service.msg_store.dest_location_not_allowed(),
)
+ def test_unload_set_destination_location_move_not_allowed(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ move_line = self.picking1.move_line_ids
+ move_line[0].move_id.location_dest_id = self.packing_sublocation_a
+ # set the destination package
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": self.free_package.id,
+ "barcode": self.packing_sublocation_b.barcode,
+ },
+ )
+ self.assert_response_unload_set_destination(
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=self.service.msg_store.dest_location_not_allowed(),
+ )
+
def test_unload_set_destination_confirm_location(self):
zone_location = self.zone_location
picking_type = self.picking1.picking_type_id
From c8317fccd8abcc4b1440e105f9660d3419643648 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 31 Aug 2020 16:11:47 +0200
Subject: [PATCH 350/940] bknd: cluster-picking split move line split code
---
shopfloor/models/stock_move_line.py | 33 +++++++++++++++++++++++++++
shopfloor/services/cluster_picking.py | 25 ++------------------
2 files changed, 35 insertions(+), 23 deletions(-)
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index f92dedea7a..388cad6a1a 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -1,5 +1,6 @@
from odoo import _, fields, models
from odoo.exceptions import UserError
+from odoo.tools.float_utils import float_compare
class StockMoveLine(models.Model):
@@ -107,3 +108,35 @@ def _split_pickings_from_source_location(self):
new_moves._action_assign()
pickings = new_picking
return pickings
+
+ def _check_qty_to_be_done(self, qty_done, split_partial=True):
+ """Check qty to be done for current move line. Split it if needed.
+
+ :param qty_done: qty expected to be done
+ :param split_partial: split if qty is less than expected
+ otherwise rely on a backorder.
+ """
+ # store a new line if we have split our line (not enough qty)
+ new_line = self.env["stock.move.line"]
+ rounding = self.product_uom_id.rounding
+ compare = float_compare(
+ qty_done, self.product_uom_qty, precision_rounding=rounding
+ )
+ qty_lesser = compare == -1
+ qty_greater = compare == 1
+ if qty_greater:
+ return (new_line, "greater")
+ elif qty_lesser:
+ if not split_partial:
+ return (new_line, "lesser")
+ # split the move line which will be processed later (maybe the user
+ # has to pick some goods from another place because the location
+ # contained less items than expected)
+ remaining = self.product_uom_qty - qty_done
+ new_line = self.copy({"product_uom_qty": remaining, "qty_done": 0})
+ # if we didn't bypass reservation update, the quant reservation
+ # would be reduced as much as the deduced quantity, which is wrong
+ # as we only moved the quantity to a new move line
+ self.with_context(bypass_reservation_update=True).product_uom_qty = qty_done
+ return (new_line, "lesser")
+ return (new_line, "full")
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 9d45a70b31..aad0438fa7 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1,6 +1,5 @@
from odoo import _, fields
from odoo.osv import expression
-from odoo.tools.float_utils import float_compare
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
@@ -568,32 +567,12 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit
batch, message=self.msg_store.operation_not_found()
)
- # store a new line if we have split our line (not enough qty)
- new_line = self.env["stock.move.line"]
-
- rounding = move_line.product_uom_id.rounding
- compare = float_compare(
- quantity, move_line.product_uom_qty, precision_rounding=rounding
- )
- qty_lesser = compare == -1
- qty_greater = compare == 1
- if qty_greater:
+ new_line, qty_check = move_line._check_qty_to_be_done(quantity)
+ if qty_check == "greater":
return self._response_for_scan_destination(
move_line,
message=self.msg_store.unable_to_pick_more(move_line.product_uom_qty),
)
- elif qty_lesser:
- # split the move line which will be processed later (maybe the user
- # has to pick some goods from another place because the location
- # contained less items than expected)
- remaining = move_line.product_uom_qty - quantity
- new_line = move_line.copy({"product_uom_qty": remaining, "qty_done": 0})
- # if we didn't bypass reservation update, the quant reservation
- # would be reduced as much as the deduced quantity, which is wrong
- # as we only moved the quantity to a new move line
- move_line.with_context(
- bypass_reservation_update=True
- ).product_uom_qty = quantity
search = self.actions_for("search")
bin_package = search.package_from_scan(barcode)
From 19c4583352ae018d13fa56123771f393f6b8cd80 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 31 Aug 2020 16:45:40 +0200
Subject: [PATCH 351/940] bknd: checkout edit qty split if less qty done
When editing the qty done on checkout/packing scenario
the if the qty is not fully done, the line is split
so we can process the remaining qty into another package.
---
shopfloor/models/stock_move_line.py | 6 ++--
shopfloor/services/checkout.py | 25 ++++++++-----
.../test_checkout_select_package_base.py | 6 ++--
shopfloor/tests/test_checkout_set_qty.py | 36 +++++++++++++++++--
4 files changed, 58 insertions(+), 15 deletions(-)
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index 388cad6a1a..fbc051f8ee 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -109,7 +109,7 @@ def _split_pickings_from_source_location(self):
pickings = new_picking
return pickings
- def _check_qty_to_be_done(self, qty_done, split_partial=True):
+ def _check_qty_to_be_done(self, qty_done, split_partial=True, **split_default_vals):
"""Check qty to be done for current move line. Split it if needed.
:param qty_done: qty expected to be done
@@ -133,7 +133,9 @@ def _check_qty_to_be_done(self, qty_done, split_partial=True):
# has to pick some goods from another place because the location
# contained less items than expected)
remaining = self.product_uom_qty - qty_done
- new_line = self.copy({"product_uom_qty": remaining, "qty_done": 0})
+ vals = {"product_uom_qty": remaining, "qty_done": 0}
+ vals.update(split_default_vals)
+ new_line = self.copy(vals)
# if we didn't bypass reservation update, the quant reservation
# would be reduced as much as the deduced quantity, which is wrong
# as we only moved the quantity to a new move line
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 91ba2565b8..7988ae4e60 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -496,22 +496,29 @@ def _change_line_qty(
message = self.msg_store.record_not_found()
for move_line in move_lines:
qty_done = quantity_func(move_line)
- if qty_done > move_line.product_uom_qty:
- qty_done = move_line.product_uom_qty
- message = {
- "body": _(
- "Not allowed to pack more than the quantity, "
- "the value has been changed to the maximum."
- ),
- "message_type": "warning",
- }
if qty_done < 0:
message = {
"body": _("Negative quantity not allowed."),
"message_type": "error",
}
else:
+ new_line = self.env["stock.move.line"]
+ if qty_done > 0:
+ new_line, qty_check = move_line._check_qty_to_be_done(
+ qty_done, split_partial=True, result_package_id=False,
+ )
+ if qty_check == "greater":
+ qty_done = move_line.product_uom_qty
+ message = {
+ "body": _(
+ "Not allowed to pack more than the quantity, "
+ "the value has been changed to the maximum."
+ ),
+ "message_type": "warning",
+ }
move_line.qty_done = qty_done
+ if new_line:
+ selected_line_ids.append(new_line.id)
return self._response_for_select_package(
picking,
self.env["stock.move.line"].browse(selected_line_ids).exists(),
diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py
index 93c7670485..845c6d8df0 100644
--- a/shopfloor/tests/test_checkout_select_package_base.py
+++ b/shopfloor/tests/test_checkout_select_package_base.py
@@ -8,7 +8,7 @@ def _assert_selected_response(
next_state="select_package",
data={
"selected_move_lines": [
- self._move_line_data(ml) for ml in selected_lines
+ self._move_line_data(ml) for ml in selected_lines.sorted()
],
"picking": self._picking_summary_data(picking),
"packing_info": picking.shopfloor_packing_info if packing_info else "",
@@ -26,7 +26,9 @@ def _assert_selected_qties(
):
picking = selected_lines.mapped("picking_id")
deselected_lines = picking.move_line_ids - selected_lines
- self.assertEqual(selected_lines.ids, [l.id for l in lines_quantities])
+ self.assertEqual(
+ sorted(selected_lines.ids), sorted([l.id for l in lines_quantities])
+ )
for line, quantity in lines_quantities.items():
self.assertEqual(line.qty_done, quantity)
for line in deselected_lines:
diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py
index 5440e473d4..99229c7e0d 100644
--- a/shopfloor/tests/test_checkout_set_qty.py
+++ b/shopfloor/tests/test_checkout_set_qty.py
@@ -127,7 +127,8 @@ def test_set_custom_qty_ok(self):
selected_lines = self.moves_pack1.move_line_ids
line_to_change = selected_lines[0]
line_keep_qty = selected_lines[1]
- new_qty = 5
+ # Process full qty
+ new_qty = line_to_change.product_uom_qty
# we want to check that when we give the package id, we get
# all its move lines
response = self.service.dispatch(
@@ -136,7 +137,7 @@ def test_set_custom_qty_ok(self):
"picking_id": self.picking.id,
"selected_line_ids": selected_lines.ids,
"move_line_id": line_to_change.id,
- "qty_done": 5,
+ "qty_done": new_qty,
},
)
self.assertEqual(line_to_change.qty_done, new_qty)
@@ -218,3 +219,34 @@ def test_set_custom_qty_negative(self):
"message_type": "error",
},
)
+
+ def test_set_custom_qty_partial(self):
+ selected_lines = self.moves_pack1.move_line_ids
+ line_to_change = selected_lines[0]
+ line_keep_qty = selected_lines[1]
+ # split 1 qty
+ new_qty = line_to_change.product_uom_qty - 1
+ response = self.service.dispatch(
+ "set_custom_qty",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": selected_lines.ids,
+ "move_line_id": line_to_change.id,
+ "qty_done": new_qty,
+ },
+ )
+ self.assertEqual(line_to_change.qty_done, new_qty)
+ self.assertEqual(line_keep_qty.qty_done, line_keep_qty.product_uom_qty)
+ new_line = [
+ x for x in self.moves_pack1.move_line_ids if x not in selected_lines
+ ][0]
+ self.assertEqual(new_line.product_uom_qty, 1.0)
+ self._assert_selected_qties(
+ response,
+ self.moves_pack1.move_line_ids,
+ {
+ line_to_change: new_qty,
+ line_keep_qty: line_keep_qty.product_uom_qty,
+ new_line: 0.0,
+ },
+ )
From bd279b06c84e855f205c92ba7d69f20c48f82074 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Tue, 1 Sep 2020 12:33:52 +0200
Subject: [PATCH 352/940] shopfloor: fix send_confirmation_email handling from
pickings
* Confirmation email (and delivery labels) have to be generated when moves are
validated (already done) and also when the validation is done from the
transfer itself.
* ensure that the 'action_done' method of the picking is called when the
last move is validated (this will trigger the send_confirmation_email
and other methods)
---
shopfloor/models/stock_move.py | 8 ++---
shopfloor/models/stock_picking.py | 9 ------
...ransfer_set_destination_package_or_line.py | 30 +++++++------------
shopfloor/tests/test_single_pack_transfer.py | 24 +++++----------
.../test_zone_picking_set_line_destination.py | 24 +++++----------
.../tests/test_zone_picking_unload_all.py | 6 ++--
...est_zone_picking_unload_set_destination.py | 12 +++-----
7 files changed, 35 insertions(+), 78 deletions(-)
diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py
index c641389dcb..1ba009e8cb 100644
--- a/shopfloor/models/stock_move.py
+++ b/shopfloor/models/stock_move.py
@@ -26,14 +26,12 @@ def split_other_move_lines(self, move_lines, intersection=False):
return False
def _action_done(self, cancel_backorder=False):
- # Overloaded to send the email when the last move of a picking is validated.
- # The method 'stock.picking._send_confirmation_email' is called only from
- # the 'stock.picking.action_done()' method but never when moves are
- # validated partially through the current method.
+ # Overloaded to ensure that the 'action_done' method of the picking
+ # is called when the last move of a picking is validated.
moves = super()._action_done(cancel_backorder)
if not self.env.context.get("_action_done_from_picking"):
pickings = moves.picking_id
for picking in pickings:
if picking.state == "done":
- picking._send_confirmation_email()
+ picking.action_done()
return moves
diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py
index 90e10fd131..7f1714e374 100644
--- a/shopfloor/models/stock_picking.py
+++ b/shopfloor/models/stock_picking.py
@@ -46,12 +46,3 @@ def _create_backorder(self):
def action_done(self):
self = self.with_context(_action_done_from_picking=True)
return super().action_done()
-
- def _send_confirmation_email(self):
- # Avoid sending the confirmation email twice (one when the
- # 'picking.action_done()' is called, and one when the last move of this
- # picking is validated through 'move._action_done()')
- # We send the confirmation email
- if self.env.context.get("_action_done_from_picking"):
- return
- super()._send_confirmation_email()
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
index 92e58bc17a..5f6815eb1f 100644
--- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
@@ -154,9 +154,7 @@ def test_set_destination_package_dest_location_to_confirm(self):
def test_set_destination_package_dest_location_ok(self):
"""Scanned destination location valid, moves set to done."""
package_level = self.picking1.package_level_ids[0]
- with mock.patch.object(
- type(self.picking1), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking1), "action_done") as action_done:
response = self.service.dispatch(
"set_destination_package",
params={
@@ -165,7 +163,7 @@ def test_set_destination_package_dest_location_ok(self):
"barcode": self.dest_location.barcode,
},
)
- send_confirmation_email.assert_called_once()
+ action_done.assert_called_once()
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
response,
@@ -351,9 +349,7 @@ def test_set_destination_line_partial_qty(self):
self.assertEqual(move_line_c.move_id.state, "done")
# Scan remaining qty (4/10)
remaining_move_line_c = move_product_c_splitted.move_line_ids
- with mock.patch.object(
- type(self.picking2), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking2), "action_done") as action_done:
response = self.service.dispatch(
"set_destination_line",
params={
@@ -363,7 +359,7 @@ def test_set_destination_line_partial_qty(self):
"barcode": self.dest_location.barcode,
},
)
- send_confirmation_email.assert_not_called()
+ action_done.assert_not_called()
# Check move line data
self.assertEqual(remaining_move_line_c.move_id.product_uom_qty, 4)
self.assertEqual(remaining_move_line_c.product_uom_qty, 0)
@@ -383,9 +379,7 @@ def test_set_destination_line_partial_qty(self):
move_line_d = self.picking2.move_line_ids.filtered(
lambda m: m.product_id == self.product_d
)
- with mock.patch.object(
- type(self.picking2), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking2), "action_done") as action_done:
response = self.service.dispatch(
"set_destination_line",
params={
@@ -400,7 +394,7 @@ def test_set_destination_line_partial_qty(self):
self.assertEqual(move_line_d.qty_done, 10)
self.assertEqual(move_line_d.state, "done")
self.assertEqual(self.picking2.state, "done")
- send_confirmation_email.assert_called_once()
+ action_done.assert_called_once()
class LocationContentTransferSetDestinationXSpecialCase(
@@ -564,9 +558,7 @@ def test_set_destination_line_split_move(self):
remaining_move_lines = self.picking.move_line_ids_without_package.filtered(
lambda ml: ml.state == "assigned"
)
- with mock.patch.object(
- type(self.picking), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking), "action_done") as action_done:
for ml in remaining_move_lines:
self.service.dispatch(
"set_destination_line",
@@ -578,11 +570,9 @@ def test_set_destination_line_split_move(self):
},
)
self.assertEqual(self.picking.state, "assigned")
- send_confirmation_email.assert_not_called()
+ action_done.assert_not_called()
package_level = self.picking.package_level_ids[0]
- with mock.patch.object(
- type(self.picking), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking), "action_done") as action_done:
self.service.dispatch(
"set_destination_package",
params={
@@ -592,4 +582,4 @@ def test_set_destination_line_split_move(self):
},
)
self.assertEqual(self.picking.state, "done")
- send_confirmation_email.assert_called_once()
+ action_done.assert_called_once()
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 3397279830..72cdd8ba5d 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -399,9 +399,7 @@ def test_validate(self):
# now, call the service to proceed with validation of the
# movement
- with mock.patch.object(
- type(self.picking), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking), "action_done") as action_done:
response = self.service.dispatch(
"validate",
params={
@@ -409,7 +407,7 @@ def test_validate(self):
"location_barcode": self.shelf2.barcode,
},
)
- send_confirmation_email.assert_called_once()
+ action_done.assert_called_once()
self.assert_response(
response,
@@ -471,9 +469,7 @@ def test_validate_completion_info(self):
# now, call the service to proceed with validation of the
# movement
- with mock.patch.object(
- type(self.picking), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking), "action_done") as action_done:
response = self.service.dispatch(
"validate",
params={
@@ -481,7 +477,7 @@ def test_validate_completion_info(self):
"location_barcode": self.shelf2.barcode,
},
)
- send_confirmation_email.assert_called_once()
+ action_done.assert_called_once()
self.assert_response(
response,
@@ -605,9 +601,7 @@ def test_validate_location_to_confirm(self):
# expected destination is 'shelf2', we'll scan shelf1 which must
# ask a confirmation to the user (it's still in the same picking type)
- with mock.patch.object(
- type(self.picking), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking), "action_done") as action_done:
response = self.service.dispatch(
"validate",
params={
@@ -615,7 +609,7 @@ def test_validate_location_to_confirm(self):
"location_barcode": self.shelf1.barcode,
},
)
- send_confirmation_email.assert_not_called()
+ action_done.assert_not_called()
message = self.service.actions_for("message").confirm_location_changed(
self.shelf2, self.shelf1
@@ -655,9 +649,7 @@ def test_validate_location_with_confirm(self):
# expected destination is 'shelf1', we'll scan shelf2 which must
# ask a confirmation to the user (it's still in the same picking type)
- with mock.patch.object(
- type(self.picking), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking), "action_done") as action_done:
response = self.service.dispatch(
"validate",
params={
@@ -667,7 +659,7 @@ def test_validate_location_with_confirm(self):
"confirmation": True,
},
)
- send_confirmation_email.assert_called_once()
+ action_done.assert_called_once()
self.assert_response(
response,
diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py
index f163958781..9986f78489 100644
--- a/shopfloor/tests/test_zone_picking_set_line_destination.py
+++ b/shopfloor/tests/test_zone_picking_set_line_destination.py
@@ -180,9 +180,7 @@ def test_set_destination_location_no_other_move_line_full_qty(self):
self.assertEqual(len(moves_before), 1)
self.assertEqual(len(moves_before.move_line_ids), 1)
move_line = moves_before.move_line_ids
- with mock.patch.object(
- type(self.picking1), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking1), "action_done") as action_done:
response = self.service.dispatch(
"set_destination",
params={
@@ -194,7 +192,7 @@ def test_set_destination_location_no_other_move_line_full_qty(self):
"confirmation": False,
},
)
- send_confirmation_email.assert_called_once()
+ action_done.assert_called_once()
# Check picking data
moves_after = self.picking1.move_lines
self.assertEqual(moves_before, moves_after)
@@ -234,9 +232,7 @@ def test_set_destination_location_no_other_move_line_partial_qty(self):
move_line = moves_before.move_line_ids
# we need a destination package if we want to scan a destination location
move_line.result_package_id = self.free_package
- with mock.patch.object(
- type(self.picking3), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking3), "action_done") as action_done:
response = self.service.dispatch(
"set_destination",
params={
@@ -248,7 +244,7 @@ def test_set_destination_location_no_other_move_line_partial_qty(self):
"confirmation": False,
},
)
- send_confirmation_email.assert_not_called()
+ action_done.assert_not_called()
self.assert_response_set_line_destination(
response,
zone_location,
@@ -285,9 +281,7 @@ def test_set_destination_location_several_move_line_full_qty(self):
# we need a destination package if we want to scan a destination location
move_line.result_package_id = self.free_package
other_move_line = moves_before.move_line_ids[1]
- with mock.patch.object(
- type(self.picking4), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking4), "action_done") as action_done:
response = self.service.dispatch(
"set_destination",
params={
@@ -299,7 +293,7 @@ def test_set_destination_location_several_move_line_full_qty(self):
"confirmation": False,
},
)
- send_confirmation_email.assert_not_called()
+ action_done.assert_not_called()
# Check picking data (move has been split in two, 6 done and 4 remaining)
moves_after = self.picking4.move_lines
self.assertEqual(len(moves_after), 2)
@@ -348,9 +342,7 @@ def test_set_destination_location_several_move_line_partial_qty(self):
move_line = moves_before.move_line_ids[0]
# we need a destination package if we want to scan a destination location
move_line.result_package_id = self.free_package
- with mock.patch.object(
- type(self.picking4), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking4), "action_done") as action_done:
response = self.service.dispatch(
"set_destination",
params={
@@ -362,7 +354,7 @@ def test_set_destination_location_several_move_line_partial_qty(self):
"confirmation": False,
},
)
- send_confirmation_email.assert_not_called()
+ action_done.assert_not_called()
self.assert_response_set_line_destination(
response,
zone_location,
diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py
index 7a37d01241..8b29c74687 100644
--- a/shopfloor/tests/test_zone_picking_unload_all.py
+++ b/shopfloor/tests/test_zone_picking_unload_all.py
@@ -197,9 +197,7 @@ def test_set_destination_all_ok(self):
another_package,
)
# set destination location for all lines in the buffer
- with mock.patch.object(
- type(self.picking5), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking5), "action_done") as action_done:
response = self.service.dispatch(
"set_destination_all",
params={
@@ -208,7 +206,7 @@ def test_set_destination_all_ok(self):
"barcode": self.packing_location.barcode,
},
)
- send_confirmation_email.assert_called_once()
+ action_done.assert_called_once()
# check data
self.assertEqual(self.picking5.state, "done")
# buffer should be empty
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
index be32d2295a..b1f01a6de8 100644
--- a/shopfloor/tests/test_zone_picking_unload_set_destination.py
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -223,9 +223,7 @@ def test_unload_set_destination_ok_buffer_empty(self):
move_line.product_uom_qty,
self.free_package,
)
- with mock.patch.object(
- type(self.picking1), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking1), "action_done") as action_done:
response = self.service.dispatch(
"unload_set_destination",
params={
@@ -236,7 +234,7 @@ def test_unload_set_destination_ok_buffer_empty(self):
"confirmation": True,
},
)
- send_confirmation_email.assert_called_once()
+ action_done.assert_called_once()
# check data
self.assertEqual(move_line.location_dest_id, packing_sublocation)
self.assertEqual(move_line.move_id.state, "done")
@@ -268,9 +266,7 @@ def test_unload_set_destination_ok_buffer_not_empty(self):
package_dest,
)
# process 1/2 buffer line
- with mock.patch.object(
- type(self.picking5), "_send_confirmation_email"
- ) as send_confirmation_email:
+ with mock.patch.object(type(self.picking5), "action_done") as action_done:
response = self.service.dispatch(
"unload_set_destination",
params={
@@ -280,7 +276,7 @@ def test_unload_set_destination_ok_buffer_not_empty(self):
"barcode": self.packing_location.barcode,
},
)
- send_confirmation_email.assert_not_called()
+ action_done.assert_not_called()
# check data
move_line = self.picking5.move_line_ids.filtered(
lambda l: l.result_package_id == self.free_package
From c43ff0e241fe78dee3f23bd4b2b5b5a798c137da Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 7 Sep 2020 15:39:48 +0200
Subject: [PATCH 353/940] bknd: fix vendor code must be always a string
---
shopfloor/actions/data.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 0ad4f88d10..5603890e76 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -225,7 +225,7 @@ def _product_supplier_code(self, rec, field):
supplier_info = fields.first(
rec.seller_ids.filtered(lambda x: x.product_id == rec)
)
- return supplier_info.product_code if supplier_info else ""
+ return supplier_info.product_code or ""
def picking_batch(self, record, with_pickings=False, **kw):
parser = self._picking_batch_parser
From 5628658f794fe215fb73998e20d224fdccb0163b Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 8 Sep 2020 10:45:00 +0200
Subject: [PATCH 354/940] location transfer: ensure move state is recomputed
(#66)
When we split a move, the state of the remaining part may remain in
"waiting" if we do not call "move._recompute_state()".
In the order of events:
* the move is split, the remaining part becomes a "waiting" move
* the existing remaining move lines are directly assigned to the
"waiting move"
* when _action_assign() is called, as the move is "waiting", it checks
if it needs reservation, but as it already has all the move lines,
it does not change the move to "assigned"
By calling "move._recompute_state()", if the reservation is already
full, the state becomes "assigned" (and the move is ignored by
"_action_assign()").
---
shopfloor/models/stock_move.py | 1 +
...ransfer_set_destination_package_or_line.py | 158 ++++++++++++++++--
2 files changed, 149 insertions(+), 10 deletions(-)
diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py
index 1ba009e8cb..acf7371576 100644
--- a/shopfloor/models/stock_move.py
+++ b/shopfloor/models/stock_move.py
@@ -21,6 +21,7 @@ def split_other_move_lines(self, move_lines, intersection=False):
backorder_move_id = self._split(qty_to_split)
backorder_move = self.browse(backorder_move_id)
backorder_move.move_line_ids = other_move_lines
+ backorder_move._recompute_state()
backorder_move._action_assign()
return backorder_move
return False
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
index 5f6815eb1f..0543243704 100644
--- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
@@ -311,6 +311,7 @@ def test_set_destination_line_partial_qty(self):
move_line_c = self.picking2.move_line_ids.filtered(
lambda m: m.product_id == self.product_c
)
+ move = move_line_c.move_id
self.assertEqual(move_line_c.product_uom_qty, 10)
self.assertEqual(move_line_c.qty_done, 10)
# Scan partial qty (6/10)
@@ -328,15 +329,12 @@ def test_set_destination_line_partial_qty(self):
self.assertEqual(move_line_c.product_uom_qty, 0)
self.assertEqual(move_line_c.qty_done, 6)
self.assertEqual(move_line_c.state, "done")
- # Check the new move created to handle the remaining qty
- move_product_c_splitted = self.picking2.move_lines.filtered(
- lambda m: m.product_id == self.product_c and m.state == "assigned"
- )
- self.assertEqual(move_product_c_splitted.state, "assigned")
- self.assertEqual(move_product_c_splitted.product_id, self.product_c)
- self.assertEqual(move_product_c_splitted.product_uom_qty, 4)
- self.assertEqual(move_product_c_splitted.move_line_ids.product_uom_qty, 4)
- self.assertEqual(move_product_c_splitted.move_line_ids.qty_done, 4)
+ # the move is split with the remaining
+ self.assertEqual(move.state, "assigned")
+ self.assertEqual(move.product_id, self.product_c)
+ self.assertEqual(move.product_uom_qty, 4)
+ self.assertEqual(move.move_line_ids.product_uom_qty, 4)
+ self.assertEqual(move.move_line_ids.qty_done, 4)
# Check the response
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
@@ -348,7 +346,7 @@ def test_set_destination_line_partial_qty(self):
)
self.assertEqual(move_line_c.move_id.state, "done")
# Scan remaining qty (4/10)
- remaining_move_line_c = move_product_c_splitted.move_line_ids
+ remaining_move_line_c = move.move_line_ids
with mock.patch.object(type(self.picking2), "action_done") as action_done:
response = self.service.dispatch(
"set_destination_line",
@@ -583,3 +581,143 @@ def test_set_destination_line_split_move(self):
)
self.assertEqual(self.picking.state, "done")
action_done.assert_called_once()
+
+
+class LocationContentTransferSetDestinationChainSpecialCase(
+ LocationContentTransferCommonCase
+):
+ """Tests for endpoint used from scan_destination (special cases with
+ chained pickings)
+
+ * /set_destination_package
+ * /set_destination_line
+
+ """
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ # Test split of partial qty when the moves have "move_orig_ids".
+ # We create a chain of pickings to ensure the proper state is computed
+ # for the split move.
+ cls.picking_a = picking_a = cls._create_picking(lines=[(cls.product_c, 10)])
+ cls.picking_b = picking_b = cls._create_picking(lines=[(cls.product_c, 10)])
+ # connect a and b in a chain of moves
+ for move_a in picking_a.move_lines:
+ for move_b in picking_b.move_lines:
+ if move_a.product_id == move_b.product_id:
+ move_a.move_dest_ids = move_b
+ move_b.procure_method = "make_to_order"
+
+ cls.pickings = picking_a | picking_b
+ cls._fill_stock_for_moves(picking_a.move_lines, location=cls.content_loc)
+ cls.pickings.action_assign()
+
+ cls.dest_location = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Sub Shelf 1",
+ "barcode": "subshelf1",
+ "location_id": cls.shelf1.id,
+ }
+ )
+ )
+
+ def test_set_destination_line_partial_qty_with_move_orig_ids(self):
+ """Scanned destination location with partial qty, but related moves
+ has to be split and the move has origin moves (with origin moves)
+ """
+ picking_a = self.picking_a
+ picking_b = self.picking_b
+ picking_a.move_line_ids.qty_done = 10
+ picking_a.action_done()
+ self.assertEqual(picking_a.state, "done")
+ self.assertEqual(picking_b.state, "assigned")
+ self._simulate_pickings_selected(picking_b)
+
+ move_line_c = picking_b.move_line_ids.filtered(
+ lambda m: m.product_id == self.product_c
+ )
+ move = move_line_c.move_id
+
+ self.assertEqual(move_line_c.product_uom_qty, 10)
+ self.assertEqual(move_line_c.qty_done, 10)
+ # Scan partial qty (6/10)
+ self.service.dispatch(
+ "set_destination_line",
+ params={
+ "location_id": self.content_loc.id,
+ "move_line_id": move_line_c.id,
+ "quantity": move_line_c.product_uom_qty - 4, # Scan 6 qty
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ # Check move line data
+ self.assertEqual(move_line_c.move_id.product_uom_qty, 6)
+ self.assertEqual(move_line_c.product_uom_qty, 0)
+ self.assertEqual(move_line_c.qty_done, 6)
+ self.assertEqual(move_line_c.state, "done")
+ # the move has been split
+ self.assertNotEqual(move_line_c.move_id, move)
+
+ # Check the move handling the remaining qty
+ self.assertEqual(move.state, "assigned")
+ move_line = move.move_line_ids
+ self.assertEqual(move_line.move_id.product_uom_qty, 4)
+ self.assertEqual(move_line.product_uom_qty, 4)
+ self.assertEqual(move_line.qty_done, 4)
+
+ def test_set_destination_package_partial_qty_with_move_orig_ids(self):
+ """Scanned destination location with partial qty, but related moves
+ has to be split and the move has origin moves
+ (with package and origin moves)
+ """
+ picking_a = self.picking_a
+ picking_b = self.picking_b
+
+ # we put 6 in a new package and 4 in another new package
+ package1 = self.env["stock.quant.package"].create({})
+ package2 = self.env["stock.quant.package"].create({})
+ line1 = picking_a.move_line_ids
+ line2 = line1.copy({"product_uom_qty": 4, "qty_done": 4})
+ line1.with_context(bypass_reservation_update=True).product_uom_qty = 6
+ line1.qty_done = 6
+ line1.result_package_id = package1
+ line2.result_package_id = package2
+ picking_a.action_done()
+ self.assertEqual(picking_a.state, "done")
+ self.assertEqual(picking_b.state, "assigned")
+ # we have 1 move line per package
+ self.assertEqual(len(picking_b.move_line_ids), 2)
+ self._simulate_pickings_selected(picking_b)
+
+ move_line = picking_b.move_line_ids.filtered(lambda m: m.package_id == package1)
+ move = move_line.move_id
+
+ self.assertEqual(move_line.product_uom_qty, 6.0)
+ self.assertEqual(move_line.qty_done, 6.0)
+ # Scan partial qty (6/10)
+ self.service.dispatch(
+ "set_destination_line",
+ params={
+ "location_id": self.content_loc.id,
+ "move_line_id": move_line.id,
+ "quantity": 6.0, # Scan 6 qty
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ # Check move line data
+ self.assertEqual(move_line.move_id.product_uom_qty, 6)
+ self.assertEqual(move_line.product_uom_qty, 0)
+ self.assertEqual(move_line.qty_done, 6)
+ self.assertEqual(move_line.state, "done")
+
+ # Check the move handling the remaining qty
+ remaining_move = picking_b.move_lines - move
+ self.assertEqual(remaining_move.state, "assigned")
+ remaining_move_line = remaining_move.move_line_ids
+ self.assertEqual(remaining_move_line.move_id.product_uom_qty, 4)
+ self.assertEqual(remaining_move_line.product_uom_qty, 4)
+ self.assertEqual(remaining_move_line.qty_done, 4)
From 039853eab1315d0370a84b64fdce585e4b2ed96c Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 8 Sep 2020 13:29:22 +0200
Subject: [PATCH 355/940] loc content transfer: fix line sorter
When we get an empty recordset for either move lines or pkg levels
the comparison fails because x.location_dest_id.complete_name will return False
instead of a string.
---
shopfloor/actions/location_content_transfer_sorter.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py
index 154dedb281..b404b318c3 100644
--- a/shopfloor/actions/location_content_transfer_sorter.py
+++ b/shopfloor/actions/location_content_transfer_sorter.py
@@ -46,8 +46,8 @@ def _sort_key(content):
)
def sort(self):
- content = [line for line in self.move_lines()] + [
- level for level in self.package_levels()
+ content = [line for line in self.move_lines() if line] + [
+ level for level in self.package_levels() if level
]
self._content = sorted(content, key=self._sort_key)
From 24883693032ab30bc631595d3b3aa0686c03b997 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 9 Sep 2020 07:13:33 +0200
Subject: [PATCH 356/940] backend: Prevent comparison between False and empty
string
---
shopfloor/actions/location_content_transfer_sorter.py | 2 +-
shopfloor/services/cluster_picking.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py
index b404b318c3..1878e45fe8 100644
--- a/shopfloor/actions/location_content_transfer_sorter.py
+++ b/shopfloor/actions/location_content_transfer_sorter.py
@@ -35,7 +35,7 @@ def _sort_key(content):
# postponed content after other contents
int(content.shopfloor_postponed),
# sort by shopfloor picking sequence
- content.location_dest_id.shopfloor_picking_sequence,
+ content.location_dest_id.shopfloor_picking_sequence or "",
# sort by similar destination
content.location_dest_id.complete_name,
# lines before packages (if we have raw products and packages, raw
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index aad0438fa7..d9f5f464d1 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -320,7 +320,7 @@ def _pick_next_line(self, batch, message=None, force_line=None):
def _sort_key_lines(line):
return (
line.shopfloor_postponed,
- line.location_id.shopfloor_picking_sequence,
+ line.location_id.shopfloor_picking_sequence or "",
line.location_id.name,
-int(line.move_id.priority or 1),
line.move_id.sequence,
From 0486af4c70fef5e23efba082a6d5a149c3dacdec Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 11 Sep 2020 08:31:17 +0200
Subject: [PATCH 357/940] bknd: delivery unify picking status check
---
shopfloor/actions/message.py | 12 +-
shopfloor/services/delivery.py | 138 ++++++------------
shopfloor/services/service.py | 15 ++
shopfloor/tests/test_delivery_scan_deliver.py | 4 +-
4 files changed, 73 insertions(+), 96 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index b713be668c..51dedf614c 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -72,7 +72,7 @@ def operation_not_found(self):
def stock_picking_not_found(self):
return {
"message_type": "error",
- "body": _("This transfer does not exist anymore."),
+ "body": _("This transfer does not exist or is not available anymore."),
}
def package_not_found(self):
@@ -355,12 +355,18 @@ def location_content_transfer_complete(self, location_src, location_dest):
),
}
+ def transfer_done_success(self, picking):
+ return {
+ "message_type": "success",
+ "body": _("Transfer {} done").format(picking.name),
+ }
+
def transfer_confirm_done(self):
return {
"message_type": "warning",
"body": _(
- "Not all lines have been processed, do you want to "
- "confirm partial operation ?"
+ "Not all lines have been processed with full quantity. "
+ "Do you confirm partial operation?"
),
}
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 18e9681be0..69679cfa67 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -72,26 +72,6 @@ def _response_for_confirm_done(self, picking, message=None):
message=message,
)
- def _check_picking_status(self, picking):
- """Check if `picking` can be processed.
-
- If the picking is already done, canceled or didn't belong to the
- expected picking type, a response is returned.
-
- Transitions:
- * deliver: always return here with updated data
- """
- if picking.state == "done":
- return self._response_for_deliver(message=self.msg_store.already_done())
- if picking.state not in ("assigned", "partially_available"):
- return self._response_for_deliver(
- message=self.msg_store.stock_picking_not_available(picking)
- )
- if picking.picking_type_id not in self.picking_types:
- return self._response_for_deliver(
- message=self.msg_store.cannot_move_something_in_picking_type()
- )
-
def scan_deliver(self, barcode, picking_id=None):
"""Scan a stock picking or a package/product/lot
@@ -107,7 +87,7 @@ def scan_deliver(self, barcode, picking_id=None):
than one package, a package barcode is requested, and if the product is
tracked by lot/serial, a lot is asked.
- If the barcode is a lot, the mbarcode ove lines for this lot are set to
+ If the barcode is a lot, the lines for this lot are set to
done. However, if the lot is in more than one package, a package
barcode is requested.
@@ -127,32 +107,33 @@ def scan_deliver(self, barcode, picking_id=None):
"""
search = self.actions_for("search")
picking = search.picking_from_scan(barcode)
+ barcode_valid = bool(picking)
if picking:
- response = self._check_picking_status(picking)
- if response:
- return response
- return self._response_for_deliver(picking=picking)
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_deliver(message=message)
- # We should have only a picking_id because the client was working
- # on it already, so no need to validate the picking type
if picking_id:
- picking = self.env["stock.picking"].browse(picking_id).exists()
+ picking = self.env["stock.picking"].browse(picking_id)
- package = search.package_from_scan(barcode)
- if package:
- return self._deliver_package(picking, package)
+ # Validate picking anyway
+ if not barcode_valid:
+ package = search.package_from_scan(barcode)
+ if package:
+ return self._deliver_package(picking, package)
- product = search.product_from_scan(barcode)
- if product:
- return self._deliver_product(picking, product)
+ if not barcode_valid:
+ product = search.product_from_scan(barcode)
+ if product:
+ return self._deliver_product(picking, product)
- lot = search.lot_from_scan(barcode)
- if lot:
- return self._deliver_lot(picking, lot)
+ if not barcode_valid:
+ lot = search.lot_from_scan(barcode)
+ if lot:
+ return self._deliver_lot(picking, lot)
- return self._response_for_deliver(
- picking=picking, message=self.msg_store.barcode_not_found()
- )
+ message = self.msg_store.barcode_not_found() if not barcode_valid else None
+ return self._response_for_deliver(picking=picking, message=message)
def _set_lines_done(self, lines):
"""Set done quantities on `lines`.
@@ -311,7 +292,7 @@ def _action_picking_done(self, picking):
return True
return False
- def list_stock_picking(self):
+ def list_stock_picking(self, message=None):
"""Return the list of stock pickings for the picking types
It returns only stock picking available or partially available.
@@ -320,7 +301,7 @@ def list_stock_picking(self):
* manual_selection: next state to show the list of stock pickings
"""
pickings = self.env["stock.picking"].search(self._pickings_domain(), order="id")
- return self._response_for_manual_selection(pickings)
+ return self._response_for_manual_selection(pickings, message=message)
def _pickings_domain(self):
return [
@@ -337,13 +318,13 @@ def select(self, picking_id):
* manual_selection: the selected stock picking is no longer valid
* deliver: with information about the stock.picking
"""
- picking = self.env["stock.picking"].browse(picking_id).exists()
+ picking = self.env["stock.picking"].browse(picking_id)
+ message = self._check_picking_status(picking)
+ if message:
+ return self.list_stock_picking(message=message)
if picking:
return self._response_for_deliver(picking)
- response = self.list_stock_picking()
- return self._response(
- response, message=self.msg_store.stock_picking_not_found()
- )
+ return self.list_stock_picking(message=self.msg_store.stock_picking_not_found())
def set_qty_done_pack(self, picking_id, package_id):
"""Set a package to "Done"
@@ -354,15 +335,10 @@ def set_qty_done_pack(self, picking_id, package_id):
Transitions:
* deliver: always return here with updated data
"""
- picking = self.env["stock.picking"].browse(picking_id).exists()
- if picking:
- response = self._check_picking_status(picking)
- if response:
- return response
- else:
- return self._response_for_deliver(
- message=self.msg_store.stock_picking_not_found()
- )
+ picking = self.env["stock.picking"].browse(picking_id)
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_deliver(message=message)
package = self.env["stock.quant.package"].browse(package_id).exists()
if package:
response = self._deliver_package(picking, package)
@@ -384,15 +360,10 @@ def set_qty_done_line(self, picking_id, move_line_id):
Transitions:
* deliver: always return here with updated data
"""
- picking = self.env["stock.picking"].browse(picking_id).exists()
- if picking:
- response = self._check_picking_status(picking)
- if response:
- return response
- else:
- return self._response_for_deliver(
- message=self.msg_store.stock_picking_not_found()
- )
+ picking = self.env["stock.picking"].browse(picking_id)
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_deliver(message=message)
line = self.env["stock.move.line"].browse(move_line_id).exists()
if line:
if line.package_id:
@@ -415,15 +386,10 @@ def reset_qty_done_pack(self, picking_id, package_id):
Transitions:
* deliver: always return here with updated data
"""
- picking = self.env["stock.picking"].browse(picking_id).exists()
- if picking:
- response = self._check_picking_status(picking)
- if response:
- return response
- else:
- return self._response_for_deliver(
- message=self.msg_store.stock_picking_not_found()
- )
+ picking = self.env["stock.picking"].browse(picking_id)
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_deliver(message=message)
package = self.env["stock.quant.package"].browse(package_id).exists()
if package:
lines = self.env["stock.move.line"].search(
@@ -451,15 +417,10 @@ def reset_qty_done_line(self, picking_id, move_line_id):
Transitions:
* deliver: always return here with updated data
"""
- picking = self.env["stock.picking"].browse(picking_id).exists()
- if picking:
- response = self._check_picking_status(picking)
- if response:
- return response
- else:
- return self._response_for_deliver(
- message=self.msg_store.stock_picking_not_found()
- )
+ picking = self.env["stock.picking"].browse(picking_id)
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_deliver(message=message)
line = self.env["stock.move.line"].browse(move_line_id).exists()
if line:
if line.picking_id != picking:
@@ -486,14 +447,9 @@ 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).exists()
- if picking:
- response = self._check_picking_status(picking)
- if response:
- return response
- else:
- return self._response_for_deliver(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_deliver(message=message)
if self._action_picking_done(picking):
return self._response_for_deliver(
message=self.msg_store.transfer_complete(picking)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 35dbe6fcd5..c4f212d47a 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -357,3 +357,18 @@ def picking_types(self):
)
)
return picking_types
+
+ def _check_picking_status(self, picking):
+ """Check if `picking` can be processed.
+
+ If the picking is already done, canceled or didn't belong to the
+ expected picking type, a message is returned.
+ """
+ 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 not in ("assigned", "partially_available"):
+ return self.msg_store.stock_picking_not_available(picking)
+ 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 264510337b..97524254bf 100644
--- a/shopfloor/tests/test_delivery_scan_deliver.py
+++ b/shopfloor/tests/test_delivery_scan_deliver.py
@@ -78,7 +78,7 @@ def test_scan_deliver_error_barcode_not_found(self):
"scan_deliver", params={"barcode": "NO VALID BARCODE", "picking_id": None}
)
self.assert_response_deliver(
- response, message={"message_type": "error", "body": "Barcode not found"}
+ response, message=self.service.msg_store.barcode_not_found(),
)
def test_scan_deliver_error_barcode_not_found_keep_picking(self):
@@ -91,7 +91,7 @@ def test_scan_deliver_error_barcode_not_found_keep_picking(self):
# if the client was working on a picking (it sends picking_id, then
# send refreshed data)
picking=self.picking,
- message={"message_type": "error", "body": "Barcode not found"},
+ message=self.service.msg_store.barcode_not_found(),
)
def _test_scan_set_done_ok(self, move_lines, barcode):
From 8f6f2b1e09b0588eb586917627b8aa88b4279d6b Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 11 Sep 2020 08:32:23 +0200
Subject: [PATCH 358/940] bknd: checkout unify picking status check
---
shopfloor/services/checkout.py | 118 +++++++++--------------
shopfloor/tests/test_checkout_done.py | 16 +--
shopfloor/tests/test_checkout_select.py | 8 +-
shopfloor/tests/test_checkout_summary.py | 57 +++++++++--
4 files changed, 106 insertions(+), 93 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 7988ae4e60..eb8f129649 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -277,7 +277,10 @@ def select(self, picking_id):
* select_line: the "normal" case, when the user has to put in pack/move
lines
"""
- picking = self.env["stock.picking"].browse(picking_id).exists()
+ picking = self.env["stock.picking"].browse(picking_id)
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_manual_selection(message=message)
return self._select_picking(picking, "manual_selection")
def _select_lines(self, lines):
@@ -313,10 +316,9 @@ def scan_line(self, picking_id, barcode):
screen to change the qty done and destination pack if needed
"""
picking = self.env["stock.picking"].browse(picking_id)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
search = self.actions_for("search")
@@ -464,10 +466,9 @@ 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)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
selection_lines = self._lines_to_pack(picking)
if not selection_lines:
@@ -484,10 +485,9 @@ def _change_line_qty(
self, picking_id, selected_line_ids, move_line_ids, quantity_func
):
picking = self.env["stock.picking"].browse(picking_id)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
move_lines = self.env["stock.move.line"].browse(move_line_ids).exists()
@@ -689,10 +689,9 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
to close the stock picking
"""
picking = self.env["stock.picking"].browse(picking_id)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
search = self.actions_for("search")
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
@@ -741,10 +740,9 @@ def new_package(self, picking_id, selected_line_ids):
* select_line: goes back to selection of lines to work on next lines
"""
picking = self.env["stock.picking"].browse(picking_id)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
return self._create_and_assign_new_packaging(picking, selected_lines)
@@ -759,10 +757,9 @@ def no_package(self, picking_id, selected_line_ids):
* select_line: goes back to selection of lines to work on next lines
"""
picking = self.env["stock.picking"].browse(picking_id)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
selected_lines.write(
{"shopfloor_checkout_done": True, "result_package_id": False}
@@ -786,11 +783,9 @@ 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)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
-
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
return self._response_for_select_dest_package(picking, lines)
@@ -826,10 +821,9 @@ 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)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
search = self.actions_for("search")
package = search.package_from_scan(barcode)
@@ -848,10 +842,9 @@ 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)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
package = self.env["stock.quant.package"].browse(package_id).exists()
return self._set_dest_package_from_selection(picking, lines, package)
@@ -863,10 +856,9 @@ def summary(self, picking_id):
* summary
"""
picking = self.env["stock.picking"].browse(picking_id)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
return self._response_for_summary(picking)
def _get_allowed_packaging(self):
@@ -883,11 +875,9 @@ def list_packaging(self, picking_id, package_id):
* summary: if the package_id no longer exists
"""
picking = self.env["stock.picking"].browse(picking_id)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
-
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
package = self.env["stock.quant.package"].browse(package_id).exists()
packaging_list = self._get_allowed_packaging()
return self._response_for_change_packaging(picking, package, packaging_list)
@@ -900,10 +890,9 @@ def set_packaging(self, picking_id, package_id, packaging_id):
* summary
"""
picking = self.env["stock.picking"].browse(picking_id)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
package = self.env["stock.quant.package"].browse(package_id).exists()
packaging = self.env["product.packaging"].browse(packaging_id).exists()
@@ -937,10 +926,9 @@ def cancel_line(self, picking_id, package_id=None, line_id=None):
* summary
"""
picking = self.env["stock.picking"].browse(picking_id)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
package = self.env["stock.quant.package"].browse(package_id).exists()
line = self.env["stock.move.line"].browse(line_id).exists()
@@ -982,23 +970,16 @@ def done(self, picking_id, confirmation=False):
* confirm_done: confirm a partial
"""
picking = self.env["stock.picking"].browse(picking_id)
- if not picking.exists():
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_found()
- )
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
lines = picking.move_line_ids
if not confirmation:
if not all(line.qty_done == line.product_uom_qty for line in lines):
return self._response_for_summary(
picking,
need_confirm=True,
- message={
- "message_type": "warning",
- "body": _(
- "Not all lines have been processed with full quantity. "
- "Do you confirm partial operation?"
- ),
- },
+ message=self.msg_store.transfer_confirm_done(),
)
elif not all(line.shopfloor_checkout_done for line in lines):
return self._response_for_summary(
@@ -1011,10 +992,7 @@ def done(self, picking_id, confirmation=False):
)
picking.action_done()
return self._response_for_select_document(
- message={
- "message_type": "success",
- "body": _("Transfer {} done").format(picking.name),
- }
+ message=self.msg_store.transfer_done_success(picking)
)
diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py
index bba6f7af58..c5ff9e9240 100644
--- a/shopfloor/tests/test_checkout_done.py
+++ b/shopfloor/tests/test_checkout_done.py
@@ -46,11 +46,7 @@ def test_done_partial(self):
response,
next_state="confirm_done",
data={"picking": self._stock_picking_data(self.picking, done=True)},
- message={
- "message_type": "warning",
- "body": "Not all lines have been processed with full quantity. "
- "Do you confirm partial operation?",
- },
+ message=self.service.msg_store.transfer_confirm_done(),
)
def test_done_partial_confirm(self):
@@ -65,10 +61,7 @@ def test_done_partial_confirm(self):
self.assert_response(
response,
next_state="select_document",
- message={
- "message_type": "success",
- "body": "Transfer {} done".format(self.picking.name),
- },
+ message=self.service.msg_store.transfer_done_success(self.picking),
)
@@ -124,8 +117,5 @@ def test_done_partial_confirm(self):
self.assert_response(
response,
next_state="select_document",
- message={
- "message_type": "success",
- "body": "Transfer {} done".format(self.picking.name),
- },
+ message=self.service.msg_store.transfer_done_success(self.picking),
)
diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py
index 442ab0ae14..3458b76e06 100644
--- a/shopfloor/tests/test_checkout_select.py
+++ b/shopfloor/tests/test_checkout_select.py
@@ -53,11 +53,15 @@ def _test_error(self, picking, msg):
def test_select_error_not_found(self):
picking = self._create_picking()
picking.sudo().unlink()
- self._test_error(picking, "This transfer does not exist anymore.")
+ self._test_error(
+ picking, self.service.msg_store.stock_picking_not_found()["body"]
+ )
def test_select_error_not_available(self):
picking = self._create_picking()
- self._test_error(picking, "Transfer {} is not available.".format(picking.name))
+ self._test_error(
+ picking, self.service.msg_store.stock_picking_not_available(picking)["body"]
+ )
def test_select_error_not_allowed(self):
picking = self._create_picking(picking_type=self.wh.pick_type_id)
diff --git a/shopfloor/tests/test_checkout_summary.py b/shopfloor/tests/test_checkout_summary.py
index 2d765930a8..b7d71cc271 100644
--- a/shopfloor/tests/test_checkout_summary.py
+++ b/shopfloor/tests/test_checkout_summary.py
@@ -2,22 +2,63 @@
class CheckoutSummaryCase(CheckoutCommonCase):
- def test_summary_ok(self):
- picking = self._create_picking(
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.picking = cls._create_picking(
lines=[
- (self.product_a, 10),
- (self.product_b, 10),
- (self.product_c, 10),
- (self.product_d, 10),
+ (cls.product_a, 10),
+ (cls.product_b, 10),
+ (cls.product_c, 10),
+ (cls.product_d, 10),
]
)
- response = self.service.dispatch("summary", params={"picking_id": picking.id})
+ def test_summary_picking_not_ready(self):
+ response = self.service.dispatch(
+ "summary", params={"picking_id": self.picking.id}
+ )
+ self.assert_response(
+ response,
+ next_state="select_document",
+ data={},
+ message=self.service.msg_store.stock_picking_not_available(self.picking),
+ )
+
+ def test_summary_not_fully_processed(self):
+ self._fill_stock_for_moves(self.picking.move_lines, in_package=True)
+ self.picking.action_assign()
+ # satisfy only few lines
+ for ml in self.picking.move_line_ids[:2]:
+ ml.qty_done = ml.product_uom_qty
+ ml.shopfloor_checkout_done = True
+ response = self.service.dispatch(
+ "summary", params={"picking_id": self.picking.id}
+ )
+ self.assert_response(
+ response,
+ next_state="summary",
+ data={
+ "picking": self._stock_picking_data(self.picking, done=True),
+ "all_processed": False,
+ },
+ )
+
+ def test_summary_fully_processed(self):
+ self._fill_stock_for_moves(self.picking.move_lines, in_package=True)
+ self.picking.action_assign()
+ # satisfy only all lines
+ for ml in self.picking.move_line_ids:
+ ml.qty_done = ml.product_uom_qty
+ ml.shopfloor_checkout_done = True
+ response = self.service.dispatch(
+ "summary", params={"picking_id": self.picking.id}
+ )
self.assert_response(
response,
next_state="summary",
data={
- "picking": self._stock_picking_data(picking, done=True),
+ "picking": self._stock_picking_data(self.picking, done=True),
"all_processed": True,
},
)
From 27c420c2f218dea4d85a9da28a062f535470116a Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 11 Sep 2020 10:34:19 +0200
Subject: [PATCH 359/940] bknd: fix delivery picking state check
In general we can process only pickings that are ready (state = 'assigned').
This is because even if the lines are partially available
and the picking is of type 'immediate transfer'
it will be available (in ready state) only when all the lines are available.
---
shopfloor/services/delivery.py | 34 ++++++++++++++++---
shopfloor/services/service.py | 2 +-
.../tests/test_delivery_list_stock_picking.py | 23 +++++++++++--
shopfloor/tests/test_delivery_select.py | 7 ++--
4 files changed, 56 insertions(+), 10 deletions(-)
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 69679cfa67..f3263e2563 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -156,11 +156,17 @@ def _reset_lines(self, lines):
line.qty_done = 0
def _deliver_package(self, picking, package):
- lines = package.move_line_ids
- lines = lines.filtered(
+ lines = package.move_line_ids.filtered(
lambda l: l.state in ("assigned", "partially_available")
- and l.picking_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:
+ message = "\n".join([
+ _("Package {} belongs to a picking without a valid state.").format(package.name),
+ message,
+ ])
+ return self._response_for_deliver(message=message)
if not lines:
return self._response_for_deliver(
picking=picking,
@@ -213,6 +219,15 @@ def _deliver_product(self, picking, product):
picking, message=self.msg_store.product_not_found_in_pickings()
)
+ # State of the picking might change while we reach this point: check again!
+ message = self._check_picking_status(lines.mapped("picking_id"))
+ if message:
+ message = "\n".join([
+ _("Product {} belongs to a picking without a valid state.").format(product.name),
+ message,
+ ])
+ return self._response_for_deliver(message=message)
+
new_picking = fields.first(lines.mapped("picking_id"))
# 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.
@@ -249,6 +264,15 @@ def _deliver_lot(self, picking, lot):
picking, message=self.msg_store.lot_not_found_in_pickings()
)
+ # State of the picking might change while we reach this point: check again!
+ message = self._check_picking_status(lines.mapped("picking_id"))
+ if message:
+ message = "\n".join([
+ _("Lot {} belongs to a picking without a valid state.").format(lot.name),
+ message,
+ ])
+ return self._response_for_deliver(message=message)
+
new_picking = fields.first(lines.mapped("picking_id"))
# When lots are as units outside of packages, we can select them for
@@ -306,7 +330,7 @@ def list_stock_picking(self, message=None):
def _pickings_domain(self):
return [
("picking_type_id", "in", self.picking_types.ids),
- ("state", "not in", ["cancel", "done", "waiting", "draft"]),
+ ("state", "=", "assigned"),
]
def select(self, picking_id):
@@ -446,7 +470,7 @@ def done(self, picking_id, confirm=False):
* deliver: error during action
* confirm_done: when not all lines of the stock.picking are done
"""
- picking = self.env["stock.picking"].browse(picking_id).exists()
+ picking = self.env["stock.picking"].browse(picking_id)
message = self._check_picking_status(picking)
if message:
return self._response_for_deliver(message=message)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index c4f212d47a..0a77c66666 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -368,7 +368,7 @@ def _check_picking_status(self, picking):
return self.msg_store.stock_picking_not_found()
if picking.state == "done":
return self.msg_store.already_done()
- if picking.state not in ("assigned", "partially_available"):
+ if picking.state != "assigned": # the picking must be ready
return self.msg_store.stock_picking_not_available(picking)
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_list_stock_picking.py b/shopfloor/tests/test_delivery_list_stock_picking.py
index 545a4db435..056557d407 100644
--- a/shopfloor/tests/test_delivery_list_stock_picking.py
+++ b/shopfloor/tests/test_delivery_list_stock_picking.py
@@ -17,9 +17,28 @@ def setUpClassBaseData(cls):
lines=[(cls.product_a, 10), (cls.product_b, 10)]
)
+ def test_list_stock_picking_ko(self):
+ """No picking is ready, no picking to list."""
+ response = self.service.dispatch("list_stock_picking", params={})
+ self.assert_response_manual_selection(
+ response, pickings=[],
+ )
+
def test_list_stock_picking_ok(self):
- pickings = self.picking1 | self.picking2
+ """Picking ready to list."""
+ # prepare 1st picking
+ self._fill_stock_for_moves(self.picking1.move_lines)
+ self.picking1.action_assign()
+ response = self.service.dispatch("list_stock_picking", params={})
+ # picking1 only available
+ self.assert_response_manual_selection(
+ response, pickings=self.picking1,
+ )
+ # prepare 2nd picking
+ self._fill_stock_for_moves(self.picking2.move_lines)
+ self.picking2.action_assign()
response = self.service.dispatch("list_stock_picking", params={})
+ # all pickings available
self.assert_response_manual_selection(
- response, pickings=pickings,
+ response, pickings=self.picking1 + self.picking2,
)
diff --git a/shopfloor/tests/test_delivery_select.py b/shopfloor/tests/test_delivery_select.py
index 14b214ef82..ce545658b4 100644
--- a/shopfloor/tests/test_delivery_select.py
+++ b/shopfloor/tests/test_delivery_select.py
@@ -16,6 +16,10 @@ def setUpClassBaseData(cls):
cls.picking2 = cls._create_picking(
lines=[(cls.product_a, 10), (cls.product_b, 10)]
)
+ cls._fill_stock_for_moves(cls.picking1.move_lines)
+ cls._fill_stock_for_moves(cls.picking2.move_lines)
+ cls.pickings = cls.picking1 | cls.picking2
+ cls.pickings.action_assign()
def test_select_ok(self):
response = self.service.dispatch(
@@ -24,10 +28,9 @@ def test_select_ok(self):
self.assert_response_deliver(response, picking=self.picking1)
def test_select_not_found(self):
- pickings = self.picking1 | self.picking2
response = self.service.dispatch("select", params={"picking_id": -1})
self.assert_response_manual_selection(
response,
- pickings=pickings,
+ pickings=self.pickings,
message=self.service.msg_store.stock_picking_not_found(),
)
From aa3804eff282c265a35bd84860f8f7c0c3ed1f03 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 11 Sep 2020 11:17:32 +0200
Subject: [PATCH 360/940] bknd: delivery check picking status on deliver by
pkg|lot|prod
When the user scans the barcode of a product/package/lot
the picking state might have changed already.
This change ensures that before processing the item
the picking state is valid.
---
shopfloor/services/delivery.py | 38 ++++++++++++++++++++++------------
shopfloor/services/service.py | 21 ++++++++++---------
2 files changed, 36 insertions(+), 23 deletions(-)
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index f3263e2563..debbea4126 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -1,4 +1,4 @@
-from odoo import fields
+from odoo import _, fields
from odoo.osv import expression
from odoo.tools.float_utils import float_is_zero
@@ -162,10 +162,14 @@ def _deliver_package(self, picking, package):
# State of the picking might change while we reach this point: check again!
message = self._check_picking_status(lines.mapped("picking_id"))
if message:
- message = "\n".join([
- _("Package {} belongs to a picking without a valid state.").format(package.name),
- message,
- ])
+ message = "\n".join(
+ [
+ _("Package {} belongs to a picking without a valid state.").format(
+ package.name
+ ),
+ message,
+ ]
+ )
return self._response_for_deliver(message=message)
if not lines:
return self._response_for_deliver(
@@ -222,10 +226,14 @@ def _deliver_product(self, picking, product):
# State of the picking might change while we reach this point: check again!
message = self._check_picking_status(lines.mapped("picking_id"))
if message:
- message = "\n".join([
- _("Product {} belongs to a picking without a valid state.").format(product.name),
- message,
- ])
+ message = "\n".join(
+ [
+ _("Product {} belongs to a picking without a valid state.").format(
+ product.name
+ ),
+ message,
+ ]
+ )
return self._response_for_deliver(message=message)
new_picking = fields.first(lines.mapped("picking_id"))
@@ -267,10 +275,14 @@ def _deliver_lot(self, picking, lot):
# State of the picking might change while we reach this point: check again!
message = self._check_picking_status(lines.mapped("picking_id"))
if message:
- message = "\n".join([
- _("Lot {} belongs to a picking without a valid state.").format(lot.name),
- message,
- ])
+ message = "\n".join(
+ [
+ _("Lot {} belongs to a picking without a valid state.").format(
+ lot.name
+ ),
+ message,
+ ]
+ )
return self._response_for_deliver(message=message)
new_picking = fields.first(lines.mapped("picking_id"))
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 0a77c66666..9cc6c7a8d4 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -358,17 +358,18 @@ def picking_types(self):
)
return picking_types
- def _check_picking_status(self, picking):
- """Check if `picking` can be processed.
+ def _check_picking_status(self, pickings):
+ """Check if given pickings can be processed.
If the picking is already done, canceled or didn't belong to the
expected picking type, a message is returned.
"""
- 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 != "assigned": # the picking must be ready
- return self.msg_store.stock_picking_not_available(picking)
- if picking.picking_type_id not in self.picking_types:
- return self.msg_store.cannot_move_something_in_picking_type()
+ 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 != "assigned": # the picking must be ready
+ return self.msg_store.stock_picking_not_available(picking)
+ if picking.picking_type_id not in self.picking_types:
+ return self.msg_store.cannot_move_something_in_picking_type()
From 6d8391fa4ba011f11a76d5d7204baf24f2a32301 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 11 Sep 2020 11:18:37 +0200
Subject: [PATCH 361/940] bknd: loc content transfer fix recover pickings
domain
---
shopfloor/services/location_content_transfer.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 31887876a1..a2dfcdd88b 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -147,7 +147,7 @@ def _router_single_or_all_destination(self, pickings, message=None):
def _domain_recover_pickings(self):
return [
("user_id", "=", self.env.uid),
- ("state", "in", ("assigned", "partially_available")),
+ ("state", "=", "assigned"),
("picking_type_id", "in", self.picking_types.ids),
]
From 5c79ba25e5e9aaf12611450670f38f8d1d90424d Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 14 Sep 2020 10:23:45 +0200
Subject: [PATCH 362/940] Fix zone_picking move line domain on scan_location
Include only move lines from operation types assigned to current menu item.
---
shopfloor/services/zone_picking.py | 2 ++
shopfloor/tests/test_zone_picking_start.py | 22 ++++++++++++++++++++++
2 files changed, 24 insertions(+)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index ad277ca0da..e67a521597 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -244,6 +244,8 @@ def _find_location_move_lines_domain(
]
if picking_type:
domain += [("picking_id.picking_type_id", "=", picking_type.id)]
+ else:
+ domain += [("picking_id.picking_type_id", "in", self.picking_types.ids)]
if package:
domain += [("package_id", "=", package.id)]
if product:
diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py
index b339bbd893..c8a6fc7933 100644
--- a/shopfloor/tests/test_zone_picking_start.py
+++ b/shopfloor/tests/test_zone_picking_start.py
@@ -8,6 +8,28 @@ class ZonePickingStartCase(ZonePickingCommonCase):
"""
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ # create a picking w/ a different picking type
+ # which should be excluded from picking types list
+ bad_picking_type = cls.picking_type.sudo().copy(
+ {
+ "name": "Bad type",
+ "sequence_code": "WH/BAD",
+ "default_location_src_id": cls.zone_location.id,
+ "shopfloor_menu_ids": False,
+ }
+ )
+ cls.extra_picking = extra_picking = cls._create_picking(
+ lines=[(cls.product_b, 10)], picking_type=bad_picking_type,
+ )
+ cls._fill_stock_for_moves(
+ extra_picking.move_lines, in_package=True, location=cls.zone_sublocation1
+ )
+ cls._update_qty_in_location(cls.zone_sublocation1, cls.product_b, 10)
+ extra_picking.action_assign()
+
def test_scan_location_wrong_barcode(self):
"""Scanned location invalid, no location found."""
response = self.service.dispatch(
From 566cb66ed9b5b2ff1f0ad52131a99f610aaae157 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Wed, 16 Sep 2020 10:55:41 +0200
Subject: [PATCH 363/940] Fix location content transfer tests
Tests were broken since c99bc5e0e51fb77907b520e621d193f072ef3bc5
---
shopfloor/tests/test_location_content_transfer_mix.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py
index b4c6252dba..2b440f5202 100644
--- a/shopfloor/tests/test_location_content_transfer_mix.py
+++ b/shopfloor/tests/test_location_content_transfer_mix.py
@@ -32,6 +32,8 @@ def setUpClassVars(cls, *args, **kwargs):
cls.wh.sudo().delivery_steps = "pick_pack_ship"
cls.pack_location = cls.wh.wh_pack_stock_loc_id
cls.ship_location = cls.wh.wh_output_stock_loc_id
+ # Allows zone picking to process PICK picking type
+ cls.zp_menu.sudo().picking_type_ids += cls.wh.pick_type_id
# Allows location content transfer to process PACK picking type
cls.menu.sudo().picking_type_ids = cls.wh.pack_type_id
cls.wh.pack_type_id.sudo().default_location_dest_id = cls.env.ref(
From 60afc35609f947f648e3fb315d28f6ba2e0bb93b Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 2 Sep 2020 13:32:58 +0200
Subject: [PATCH 364/940] Implement change.package.lot as a Component instead
of Mixin
---
shopfloor/actions/__init__.py | 1 +
shopfloor/actions/base_action.py | 4 ++++
.../change_package_lot.py} | 24 +++++++++++--------
shopfloor/services/cluster_picking.py | 8 +++----
shopfloor/services/zone_picking.py | 14 +++++------
5 files changed, 30 insertions(+), 21 deletions(-)
rename shopfloor/{services/change_pack_lot_mixin.py => actions/change_package_lot.py} (91%)
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index 42f45d7eee..7aa11fa809 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -17,6 +17,7 @@
"""
from . import base_action
+from . import change_package_lot
from . import data
from . import data_detail
from . import completion_info
diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py
index 76eac66eba..b094c2c812 100644
--- a/shopfloor/actions/base_action.py
+++ b/shopfloor/actions/base_action.py
@@ -10,3 +10,7 @@ class ShopFloorProcessAction(AbstractComponent):
def actions_for(self, usage):
return self.component(usage=usage)
+
+ @property
+ def msg_store(self):
+ return self.actions_for("message")
diff --git a/shopfloor/services/change_pack_lot_mixin.py b/shopfloor/actions/change_package_lot.py
similarity index 91%
rename from shopfloor/services/change_pack_lot_mixin.py
rename to shopfloor/actions/change_package_lot.py
index e1d9b078d2..5d96a56ea2 100644
--- a/shopfloor/services/change_pack_lot_mixin.py
+++ b/shopfloor/actions/change_package_lot.py
@@ -1,14 +1,20 @@
from odoo import _
+from odoo.addons.component.core import Component
-# TODO use a component instead (in actions)
-# delegation > inheritance ;)
-class ChangePackLotMixin:
- def _change_lot(self, move_line, lot, response_ok_func, response_error_func):
+
+class ChangePackageLot(Component):
+ """Provide methods for changing a package or a lot on a move line"""
+
+ _name = "shopfloor.change.package.lot.action"
+ _inherit = "shopfloor.process.action"
+ _usage = "change.package.lot"
+
+ def change_lot(self, move_line, lot, response_ok_func, response_error_func):
"""Change the lot on the move line.
- :param response_ok_func: callable used tu return ok response
- :param response_error_func: callable used tu return error response
+ :param response_ok_func: callable used to return ok response
+ :param response_error_func: callable used to return error response
"""
# If the lot is part of a package, what we really want
# is not to change the lot, but change the package (which will
@@ -32,7 +38,7 @@ def _change_lot(self, move_line, lot, response_ok_func, response_error_func):
)
if len(lot_package_quants) == 1:
package = lot_package_quants.package_id
- return self._change_pack_lot_change_package(
+ return self.change_package(
move_line, package, response_ok_func, response_error_func
)
elif len(lot_package_quants) > 1:
@@ -90,9 +96,7 @@ def _package_identical_move_lines_qty(self, package, move_lines):
return grouped_quants == grouped_lines
- def _change_pack_lot_change_package(
- self, move_line, package, response_ok_func, response_error_func
- ):
+ def change_package(self, move_line, package, response_ok_func, response_error_func):
inventory = self.actions_for("inventory")
package_level = move_line.package_level_id
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index d9f5f464d1..3ba0e10066 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -4,11 +4,10 @@
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
-from .change_pack_lot_mixin import ChangePackLotMixin
from .service import to_float
-class ClusterPicking(Component, ChangePackLotMixin):
+class ClusterPicking(Component):
"""
Methods for the Cluster Picking Process
@@ -863,9 +862,10 @@ def change_pack_lot(self, picking_batch_id, move_line_id, barcode):
search = self.actions_for("search")
response_ok_func = self._response_for_scan_destination
response_error_func = self._response_for_change_pack_lot
+ change_package_lot = self.actions_for("change.package.lot")
lot = search.lot_from_scan(barcode)
if lot:
- response = self._change_lot(
+ response = change_package_lot.change_lot(
move_line, lot, response_ok_func, response_error_func
)
if response:
@@ -873,7 +873,7 @@ def change_pack_lot(self, picking_batch_id, move_line_id, barcode):
package = search.package_from_scan(barcode)
if package:
- return self._change_pack_lot_change_package(
+ return change_package_lot.change_package(
move_line, package, response_ok_func, response_error_func
)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index e67a521597..b9e5d2f67e 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -6,11 +6,10 @@
from odoo.addons.base_rest.components.service import to_bool, to_int
from odoo.addons.component.core import Component
-from .change_pack_lot_mixin import ChangePackLotMixin
from .service import to_float
-class ZonePicking(Component, ChangePackLotMixin):
+class ZonePicking(Component):
"""
Methods for the Zone Picking Process
@@ -905,9 +904,9 @@ def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barco
if not move_line.exists():
return self._response_for_start(message=self.msg_store.record_not_found())
search = self.actions_for("search")
- # pre-configured callable used to generate the response as 'ChangePackLotMixin'
- # is not aware of the needed response type and related parameters for
- # zone picking scenario
+ # pre-configured callable used to generate the response as the
+ # change.package.lot component is not aware of the needed response type
+ # and related parameters for zone picking scenario
response_ok_func = functools.partial(
self._response_for_set_line_destination, zone_location, picking_type
)
@@ -915,16 +914,17 @@ def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barco
self._response_for_change_pack_lot, zone_location, picking_type
)
response = None
+ change_package_lot = self.actions_for("change.package.lot")
# handle lot
lot = search.lot_from_scan(barcode)
if lot:
- response = self._change_lot(
+ response = change_package_lot.change_lot(
move_line, lot, response_ok_func, response_error_func
)
# handle package
package = search.package_from_scan(barcode)
if package:
- response = self._change_pack_lot_change_package(
+ return change_package_lot.change_package(
move_line, package, response_ok_func, response_error_func
)
# if the response is not an error, we check the move_line status
From 1719e636f5d38aaaa791aecd7b770b1ad3c08033 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 17 Sep 2020 07:42:55 +0200
Subject: [PATCH 365/940] checkout: Fix flaky test for list_packaging
CheckoutListSetPackagingCase.test_list_packaging_ok is randomly failing,
because it relies on the order of the records which indeterminate.
As shopfloor depends on the module "product_packaging_type", which
changes the default ordering of "product.packaging" to:
_order = "product_id, type_sequence"
The "sequence" on the packaging is useless.
Create packaging types so the order is stable.
---
.../tests/test_checkout_change_packaging.py | 21 ++++++++++++++++---
1 file changed, 18 insertions(+), 3 deletions(-)
diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py
index 5f79c6ec40..f9488e6714 100644
--- a/shopfloor/tests/test_checkout_change_packaging.py
+++ b/shopfloor/tests/test_checkout_change_packaging.py
@@ -5,12 +5,17 @@ class CheckoutListSetPackagingCase(CheckoutCommonCase):
@classmethod
def setUpClassBaseData(cls):
super().setUpClassBaseData()
+ pallet_type = (
+ cls.env["product.packaging.type"]
+ .sudo()
+ .create({"name": "Pallet", "code": "P", "sequence": 3})
+ )
cls.packaging_pallet = (
cls.env["product.packaging"]
.sudo()
.create(
{
- "sequence": 3,
+ "packaging_type_id": pallet_type.id,
"name": "Pallet",
"barcode": "PPP",
"height": 100,
@@ -19,12 +24,17 @@ def setUpClassBaseData(cls):
}
)
)
+ box_type = (
+ cls.env["product.packaging.type"]
+ .sudo()
+ .create({"name": "Box", "code": "B", "sequence": 2})
+ )
cls.packaging_box = (
cls.env["product.packaging"]
.sudo()
.create(
{
- "sequence": 2,
+ "packaging_type_id": box_type.id,
"name": "Box",
"barcode": "BBB",
"height": 20,
@@ -33,12 +43,17 @@ def setUpClassBaseData(cls):
}
)
)
+ inner_box_type = (
+ cls.env["product.packaging.type"]
+ .sudo()
+ .create({"name": "Inner Box", "code": "I", "sequence": 1})
+ )
cls.packaging_inner_box = (
cls.env["product.packaging"]
.sudo()
.create(
{
- "sequence": 1,
+ "packaging_type_id": inner_box_type.id,
"name": "Inner Box",
"barcode": "III",
"height": 10,
From 609f5e3ad88c90d7eab555e4100871663ebb43b8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Thu, 17 Sep 2020 13:13:08 +0200
Subject: [PATCH 366/940] new method to validate moves in a separate transfer
(#72)
Calling _action_done() directly on the stock.move while leaving the
stock.picking "assigned" with the other moves already required various
workarounds as it is normally not an expected flow in Odoo.
This method is to be used in services instead of calling _action_done() on
the moves. If a move to set to done contains move lines that should not
be validated, the line to keep should be extracted beforehand with
`move_id.split_other_move_lines`.
---
shopfloor/models/stock_move.py | 44 ++++++++++-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_stock_split.py | 112 +++++++++++++++++++++++-----
3 files changed, 136 insertions(+), 21 deletions(-)
diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py
index acf7371576..da24e18d02 100644
--- a/shopfloor/models/stock_move.py
+++ b/shopfloor/models/stock_move.py
@@ -1,4 +1,4 @@
-from odoo import models
+from odoo import _, models
class StockMove(models.Model):
@@ -36,3 +36,45 @@ def _action_done(self, cancel_backorder=False):
if picking.state == "done":
picking.action_done()
return moves
+
+ def extract_and_action_done(self):
+ """Extract the moves in a separate transfer and validate them.
+
+ You can combine this method with `split_other_move_lines` method
+ to first extract some move lines in a separate move, then validate it
+ with this method.
+ """
+ moves = self.filtered(lambda m: m.state == "assigned")
+ if not moves:
+ return False
+ for picking in moves.picking_id:
+ moves_todo = picking.move_lines & moves
+ if moves_todo == picking.move_lines:
+ # No need to create a new transfer if we are processing all moves
+ new_picking = picking
+ else:
+ new_picking = picking.copy(
+ {
+ "name": "/",
+ "move_lines": [],
+ "move_line_ids": [],
+ "backorder_id": picking.id,
+ }
+ )
+ new_picking.message_post(
+ body=_(
+ "Created from backorder "
+ "%s ."
+ )
+ % (picking.id, picking.name)
+ )
+ moves_todo.write({"picking_id": new_picking.id})
+ moves_todo.package_level_id.write({"picking_id": new_picking.id})
+ moves_todo.move_line_ids.write({"picking_id": new_picking.id})
+ moves_todo.move_line_ids.package_level_id.write(
+ {"picking_id": new_picking.id}
+ )
+ new_picking.action_assign()
+ assert new_picking.state == "assigned"
+ new_picking.action_done()
+ return True
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index bfa6c153a4..ca80513434 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -51,3 +51,4 @@
from . import test_zone_picking_unload_all
from . import test_zone_picking_unload_set_destination
from . import test_misc
+from . import test_stock_split
diff --git a/shopfloor/tests/test_stock_split.py b/shopfloor/tests/test_stock_split.py
index 9a3ac8e852..b39e22878f 100644
--- a/shopfloor/tests/test_stock_split.py
+++ b/shopfloor/tests/test_stock_split.py
@@ -14,7 +14,7 @@ def setUpClass(cls):
cls.pack_location = cls.warehouse.wh_pack_stock_loc_id
cls.ship_location = cls.warehouse.wh_output_stock_loc_id
cls.stock_location = cls.env.ref("stock.stock_location_stock")
- # Create a product
+ # Create products
cls.product_a = (
cls.env["product.product"]
.sudo()
@@ -39,6 +39,30 @@ def setUpClass(cls):
}
)
)
+ cls.product_b = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product B",
+ "type": "product",
+ "default_code": "B",
+ "barcode": "B",
+ "weight": 2,
+ }
+ )
+ )
+ cls.product_b_packaging = (
+ cls.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "name": "Box",
+ "product_id": cls.product_b.id,
+ "barcode": "ProductBBox",
+ }
+ )
+ )
# Put product_a quantities in different packages to get several move lines
cls.package_1 = cls.env["stock.quant.package"].create({"name": "PACKAGE_1"})
cls.package_2 = cls.env["stock.quant.package"].create({"name": "PACKAGE_2"})
@@ -53,6 +77,8 @@ def setUpClass(cls):
cls._update_qty_in_location(
cls.stock_location, cls.product_a, 5, package=cls.package_3
)
+ # Put product_b quantities in stock
+ cls._update_qty_in_location(cls.stock_location, cls.product_b, 10)
# Create the pick/pack/ship transfer
cls.ship_move_a = cls.env["stock.move"].create(
{
@@ -68,12 +94,28 @@ def setUpClass(cls):
"state": "draft",
}
)
- cls.ship_move_a._assign_picking()
- cls.ship_move_a._action_confirm(merge=False)
- cls.pack_move = cls.ship_move_a.move_orig_ids[0]
- cls.pick_move = cls.pack_move.move_orig_ids[0]
- cls.picking = cls.pick_move.picking_id
- cls.packing = cls.pack_move.picking_id
+ cls.ship_move_b = cls.env["stock.move"].create(
+ {
+ "name": cls.product_b.display_name,
+ "product_id": cls.product_b.id,
+ "product_uom_qty": 4,
+ "product_uom": cls.product_b.uom_id.id,
+ "location_id": cls.ship_location.id,
+ "location_dest_id": cls.customer_location.id,
+ "warehouse_id": cls.warehouse.id,
+ "picking_type_id": cls.warehouse.out_type_id.id,
+ "procure_method": "make_to_order",
+ "state": "draft",
+ }
+ )
+ (cls.ship_move_a | cls.ship_move_b)._assign_picking()
+ (cls.ship_move_a | cls.ship_move_b)._action_confirm(merge=False)
+ cls.pack_move_a = cls.ship_move_a.move_orig_ids[0]
+ cls.pick_move_a = cls.pack_move_a.move_orig_ids[0]
+ cls.pack_move_b = cls.ship_move_b.move_orig_ids[0]
+ cls.pick_move_b = cls.pack_move_b.move_orig_ids[0]
+ cls.picking = cls.pick_move_a.picking_id
+ cls.packing = cls.pack_move_a.picking_id
cls.picking.action_assign()
@classmethod
@@ -90,27 +132,27 @@ def _update_qty_in_location(
)
def test_split_pickings_from_source_location(self):
- dest_location = self.pick_move.location_dest_id.sudo().copy(
+ dest_location = self.pick_move_a.location_dest_id.sudo().copy(
{
- "name": self.pick_move.location_dest_id.name + "_2",
- "barcode": self.pick_move.location_dest_id.barcode + "_2",
- "location_id": self.pick_move.location_dest_id.id,
+ "name": self.pick_move_a.location_dest_id.name + "_2",
+ "barcode": self.pick_move_a.location_dest_id.barcode + "_2",
+ "location_id": self.pick_move_a.location_dest_id.id,
}
)
# Pick goods from stock and move some of them to a different destination
- self.assertEqual(self.pick_move.state, "assigned")
- for i, move_line in enumerate(self.pick_move.move_line_ids):
+ self.assertEqual(self.pick_move_a.state, "assigned")
+ for i, move_line in enumerate(self.pick_move_a.move_line_ids):
move_line.qty_done = move_line.product_uom_qty
if i % 2:
move_line.location_dest_id = dest_location
- self.pick_move.with_context(_sf_no_backorder=True)._action_done()
- self.assertEqual(self.pick_move.state, "done")
+ self.pick_move_a.with_context(_sf_no_backorder=True)._action_done()
+ self.assertEqual(self.pick_move_a.state, "done")
# Pack step, we want to split move lines from common source location
- self.assertEqual(self.pack_move.state, "assigned")
- move_lines_to_process = self.pack_move.move_line_ids.filtered(
+ self.assertEqual(self.pack_move_a.state, "assigned")
+ move_lines_to_process = self.pack_move_a.move_line_ids.filtered(
lambda ml: ml.location_id == dest_location
)
- self.assertEqual(len(self.pack_move.move_line_ids), 3)
+ self.assertEqual(len(self.pack_move_a.move_line_ids), 3)
self.assertEqual(len(self.packing.package_level_ids), 3)
self.assertEqual(len(move_lines_to_process), 1)
new_packing = move_lines_to_process._split_pickings_from_source_location()
@@ -120,9 +162,39 @@ def test_split_pickings_from_source_location(self):
self.assertTrue(new_packing != self.packing)
self.assertEqual(new_packing.backorder_id, self.packing)
self.assertEqual(
- self.pick_move.move_dest_ids.picking_id, self.packing | new_packing
+ self.pick_move_a.move_dest_ids.picking_id, self.packing | new_packing
)
self.assertEqual(move_lines_to_process.state, "assigned")
self.assertEqual(
- set(self.pack_move.move_line_ids.mapped("state")), {"assigned"}
+ set(self.pack_move_a.move_line_ids.mapped("state")), {"assigned"}
)
+
+ def test_extract_and_action_done_one_move(self):
+ self.assertFalse(self.picking.backorder_ids)
+ self.assertEqual(self.picking.state, "assigned")
+ for move_line in self.pick_move_b.move_line_ids:
+ move_line.qty_done = move_line.product_uom_qty
+ self.pick_move_b.extract_and_action_done()
+ new_picking = self.picking.backorder_ids
+ self.assertTrue(new_picking)
+ # Check move lines repartition
+ self.assertNotIn(self.pick_move_b, self.picking.move_lines)
+ self.assertEqual(new_picking.move_lines, self.pick_move_b)
+ # Check states
+ self.assertEqual(self.picking.state, "assigned")
+ self.assertEqual(self.pick_move_b.state, "done")
+ self.assertEqual(new_picking.state, "done")
+
+ def test_extract_and_action_done_several_moves(self):
+ self.assertFalse(self.picking.backorder_ids)
+ self.assertEqual(self.picking.state, "assigned")
+ for move_line in self.picking.move_line_ids:
+ move_line.qty_done = move_line.product_uom_qty
+ self.picking.move_lines.extract_and_action_done()
+ # No backorder as all moves of the picking have been validated
+ new_picking = self.picking.backorder_ids
+ self.assertFalse(new_picking)
+ # Check move lines repartition
+ self.assertEqual(len(self.picking.move_lines), 2)
+ # Check states
+ self.assertEqual(self.picking.state, "done")
From a510f2f23191c3b2c5842257b86f688e4c925a30 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 9 Sep 2020 12:18:43 +0200
Subject: [PATCH 367/940] pack transfer: check of scanned dest. location vs
move's destination
complete commit fcab62b which added this check for other scenarios
In the single pack transfer, prevent to set a destination on the move
line which is not equal or below the move's destination.
---
shopfloor/services/single_pack_transfer.py | 11 +--
shopfloor/tests/test_single_pack_transfer.py | 77 ++++++++++++++++++--
2 files changed, 73 insertions(+), 15 deletions(-)
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index f437449b05..d6212e46be 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -134,7 +134,7 @@ def _is_dest_location_valid(self, move, scanned_location):
"""Forbid a dest location to be used"""
return scanned_location.is_sublocation_of(
move.picking_id.picking_type_id.default_location_dest_id
- )
+ ) and scanned_location.is_sublocation_of(move.location_dest_id)
def _is_dest_location_to_confirm(self, move, scanned_location):
"""Destination that could be used but need confirmation"""
@@ -172,14 +172,7 @@ def validate(self, package_level_id, location_barcode, confirmation=False):
)
if self._is_dest_location_to_confirm(move, scanned_location):
- if confirmation:
- # If the destination of the move would be incoherent
- # (move line outside of it), we change the moves' destination
- # TODO in other scenarios, we forbid this. Check if we want
- # to forbid it as well.
- if not scanned_location.is_sublocation_of(move.location_dest_id):
- move.location_dest_id = scanned_location.id
- else:
+ if not confirmation:
return self._response_for_scan_location(
package_level,
confirmation_required=True,
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 72cdd8ba5d..170321fee0 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -73,8 +73,8 @@ def _response_package_level_data(self, package_level):
return {
"id": package_level.id,
"name": package_level.package_id.name,
- "location_src": self.data.location(self.shelf1),
- "location_dest": self.data.location(self.shelf2),
+ "location_src": self.data.location(package_level.location_id),
+ "location_dest": self.data.location(package_level.location_dest_id),
"picking": self.data.picking(self.picking),
"product": self.data.product(self.product_a),
}
@@ -547,7 +547,7 @@ def test_validate_location_not_found(self):
)
def test_validate_location_forbidden(self):
- """Test a call on /validate on a forbidden location
+ """Test a call on /validate on a forbidden location (not child of type)
The pre-conditions:
@@ -557,7 +557,7 @@ def test_validate_location_forbidden(self):
* No change in odoo, Transition with a message
- Note: a forbidden location is when a location is not a child
+ Note: the location is forbidden when a location is not a child
of the destination location of the picking type used for the process
"""
# setup the picking as we need, like if the move line
@@ -580,6 +580,47 @@ def test_validate_location_forbidden(self):
message={"message_type": "error", "body": "You cannot place it here"},
)
+ def test_validate_location_forbidden_move_invalid(self):
+ """Test a call on /validate on a forbidden location (not child of move)
+
+ The pre-conditions:
+
+ * /start has been called
+
+ Expected result:
+
+ * No change in odoo, Transition with a message
+
+ Note: the location is forbidden when a location is not a child
+ of the destination location of the move
+ """
+ # setup the picking as we need, like if the move line
+ # was already started by the first step (start operation)
+ package_level = self._simulate_started()
+
+ move = package_level.move_line_ids.move_id
+ # take the parent of the expected dest.: not allowed
+ location = move.location_dest_id.location_id
+ # allow this location to be used in the picking type, otherwise,
+ # we check the wrong condition
+ self.picking_type.sudo().default_location_dest_id = location
+
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ # this location is outside of the expected destination
+ "location_barcode": location.barcode,
+ },
+ )
+
+ self.assert_response(
+ response,
+ next_state="scan_location",
+ data=self.ANY,
+ message={"message_type": "error", "body": "You cannot place it here"},
+ )
+
def test_validate_location_to_confirm(self):
"""Test a call on /validate on a location to confirm
@@ -599,20 +640,44 @@ def test_validate_location_to_confirm(self):
# was already started by the first step (start operation)
package_level = self._simulate_started()
+ sub_shelf1 = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "subshelf1",
+ "barcode": "subshelf1",
+ "location_id": self.shelf2.id,
+ }
+ )
+ )
+ sub_shelf2 = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "subshelf2",
+ "barcode": "subshelf2",
+ "location_id": self.shelf2.id,
+ }
+ )
+ )
+
# expected destination is 'shelf2', we'll scan shelf1 which must
# ask a confirmation to the user (it's still in the same picking type)
+ package_level.location_dest_id = sub_shelf1
with mock.patch.object(type(self.picking), "action_done") as action_done:
response = self.service.dispatch(
"validate",
params={
"package_level_id": package_level.id,
- "location_barcode": self.shelf1.barcode,
+ "location_barcode": sub_shelf2.barcode,
},
)
action_done.assert_not_called()
message = self.service.actions_for("message").confirm_location_changed(
- self.shelf2, self.shelf1
+ sub_shelf1, sub_shelf2
)
self.assert_response(
response,
From 69be16754cbf2d1fdeba668886497de0080c6495 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 17 Sep 2020 13:28:38 +0200
Subject: [PATCH 368/940] single pack transfer: extract move in new picking and
use action_done
Calling _action_done() directly on the stock.move while leaving the
stock.picking "assigned" with the other moves already required various
workarounds as it is normally not an expected flow in Odoo.
A previous commit added a method "extract_and_action_done" on the
moves, which extract the move(s) to set to done in its own stock.picking
and call action_done() on it. We ensure it works as intented by the
stock module.
The remaining code for the workarounds (_sf_no_backorder, ...) will be
removed in a subsequent commit.
---
shopfloor/services/single_pack_transfer.py | 2 +-
shopfloor/tests/test_single_pack_transfer.py | 71 +++++++++-----------
2 files changed, 32 insertions(+), 41 deletions(-)
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index d6212e46be..46f5526225 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -202,7 +202,7 @@ def _set_destination_and_done(self, move, scanned_location):
# when writing the destination on the package level, it writes
# on the move lines
move.move_line_ids.package_level_id.location_dest_id = scanned_location
- move._action_done()
+ move.extract_and_action_done()
def cancel(self, package_level_id):
package_level = self.env["stock.package_level"].browse(package_level_id)
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 170321fee0..298ab080c2 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -1,5 +1,3 @@
-from unittest import mock
-
from odoo.tests.common import Form
from .common import CommonCase
@@ -399,15 +397,13 @@ def test_validate(self):
# now, call the service to proceed with validation of the
# movement
- with mock.patch.object(type(self.picking), "action_done") as action_done:
- response = self.service.dispatch(
- "validate",
- params={
- "package_level_id": package_level.id,
- "location_barcode": self.shelf2.barcode,
- },
- )
- action_done.assert_called_once()
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ },
+ )
self.assert_response(
response,
@@ -469,15 +465,14 @@ def test_validate_completion_info(self):
# now, call the service to proceed with validation of the
# movement
- with mock.patch.object(type(self.picking), "action_done") as action_done:
- response = self.service.dispatch(
- "validate",
- params={
- "package_level_id": package_level.id,
- "location_barcode": self.shelf2.barcode,
- },
- )
- action_done.assert_called_once()
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ },
+ )
+ self.assertEqual(package_level.picking_id.state, "done")
self.assert_response(
response,
@@ -666,15 +661,13 @@ def test_validate_location_to_confirm(self):
# expected destination is 'shelf2', we'll scan shelf1 which must
# ask a confirmation to the user (it's still in the same picking type)
package_level.location_dest_id = sub_shelf1
- with mock.patch.object(type(self.picking), "action_done") as action_done:
- response = self.service.dispatch(
- "validate",
- params={
- "package_level_id": package_level.id,
- "location_barcode": sub_shelf2.barcode,
- },
- )
- action_done.assert_not_called()
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": sub_shelf2.barcode,
+ },
+ )
message = self.service.actions_for("message").confirm_location_changed(
sub_shelf1, sub_shelf2
@@ -714,17 +707,15 @@ def test_validate_location_with_confirm(self):
# expected destination is 'shelf1', we'll scan shelf2 which must
# ask a confirmation to the user (it's still in the same picking type)
- with mock.patch.object(type(self.picking), "action_done") as action_done:
- response = self.service.dispatch(
- "validate",
- params={
- "package_level_id": package_level.id,
- "location_barcode": self.shelf2.barcode,
- # acknowledge the change of destination
- "confirmation": True,
- },
- )
- action_done.assert_called_once()
+ response = self.service.dispatch(
+ "validate",
+ params={
+ "package_level_id": package_level.id,
+ "location_barcode": self.shelf2.barcode,
+ # acknowledge the change of destination
+ "confirmation": True,
+ },
+ )
self.assert_response(
response,
From 611db7b553a74c1c5e2350ee72c6f63630568c1b Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 17 Sep 2020 12:58:17 +0200
Subject: [PATCH 369/940] zone picking: extract move in new picking and use
action_done
Calling _action_done() directly on the stock.move while leaving the
stock.picking "assigned" with the other moves already required various
workarounds as it is normally not an expected flow in Odoo.
A previous commit added a method "extract_and_action_done" on the
moves, which extract the move(s) to set to done in its own stock.picking
and call action_done() on it. We ensure it works as intented by the
stock module.
The remaining code for the workarounds (_sf_no_backorder, ...) will be
removed in a subsequent commit.
---
shopfloor/services/zone_picking.py | 7 +-
.../test_zone_picking_set_line_destination.py | 120 +++++++++---------
.../tests/test_zone_picking_unload_all.py | 20 ++-
...est_zone_picking_unload_set_destination.py | 66 +++++-----
4 files changed, 104 insertions(+), 109 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index b9e5d2f67e..d51ae01c0e 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -540,11 +540,10 @@ def _set_destination_location(
move_line.qty_done = quantity
# if the move has other move lines, it is split to have only this move line
move_line.move_id.split_other_move_lines(move_line)
- # set to done (without backorder)
- move_line.move_id.with_context(_sf_no_backorder=True)._action_done()
# try to re-assign any split move (in case of partial qty)
if "confirmed" in move_line.picking_id.move_lines.mapped("state"):
move_line.picking_id.action_assign()
+ move_line.move_id.extract_and_action_done()
location_changed = True
# Zero check
zero_check = picking_type.shopfloor_zero_check
@@ -1073,7 +1072,7 @@ def set_destination_all(
self._write_destination_on_lines(buffer_lines, location)
# set lines to done + refresh buffer lines (should be empty)
moves = buffer_lines.mapped("move_id")
- moves.with_context(_sf_no_backorder=True)._action_done()
+ moves.extract_and_action_done()
message = self.msg_store.buffer_complete()
buffer_lines = self._find_buffer_move_lines(zone_location, picking_type)
else:
@@ -1275,7 +1274,7 @@ def unload_set_destination(
self._write_destination_on_lines(buffer_lines, location)
# set lines to done + refresh buffer lines (should be empty)
moves = buffer_lines.mapped("move_id")
- moves.with_context(_sf_no_backorder=True)._action_done()
+ moves.extract_and_action_done()
buffer_lines = self._find_buffer_move_lines(zone_location, picking_type)
if buffer_lines:
return self._response_for_unload_single(
diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py
index 9986f78489..33fc91473c 100644
--- a/shopfloor/tests/test_zone_picking_set_line_destination.py
+++ b/shopfloor/tests/test_zone_picking_set_line_destination.py
@@ -1,5 +1,3 @@
-from unittest import mock
-
from .test_zone_picking_base import ZonePickingCommonCase
@@ -180,19 +178,18 @@ def test_set_destination_location_no_other_move_line_full_qty(self):
self.assertEqual(len(moves_before), 1)
self.assertEqual(len(moves_before.move_line_ids), 1)
move_line = moves_before.move_line_ids
- with mock.patch.object(type(self.picking1), "action_done") as action_done:
- response = self.service.dispatch(
- "set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "move_line_id": move_line.id,
- "barcode": self.packing_location.barcode,
- "quantity": move_line.product_uom_qty,
- "confirmation": False,
- },
- )
- action_done.assert_called_once()
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty,
+ "confirmation": False,
+ },
+ )
+ self.assertEqual(move_line.state, "done")
# Check picking data
moves_after = self.picking1.move_lines
self.assertEqual(moves_before, moves_after)
@@ -232,19 +229,17 @@ def test_set_destination_location_no_other_move_line_partial_qty(self):
move_line = moves_before.move_line_ids
# we need a destination package if we want to scan a destination location
move_line.result_package_id = self.free_package
- with mock.patch.object(type(self.picking3), "action_done") as action_done:
- response = self.service.dispatch(
- "set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "move_line_id": move_line.id,
- "barcode": barcode,
- "quantity": 6,
- "confirmation": False,
- },
- )
- action_done.assert_not_called()
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": barcode,
+ "quantity": 6,
+ "confirmation": False,
+ },
+ )
self.assert_response_set_line_destination(
response,
zone_location,
@@ -281,28 +276,31 @@ def test_set_destination_location_several_move_line_full_qty(self):
# we need a destination package if we want to scan a destination location
move_line.result_package_id = self.free_package
other_move_line = moves_before.move_line_ids[1]
- with mock.patch.object(type(self.picking4), "action_done") as action_done:
- response = self.service.dispatch(
- "set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "move_line_id": move_line.id,
- "barcode": self.packing_location.barcode,
- "quantity": move_line.product_uom_qty, # 6 qty
- "confirmation": False,
- },
- )
- action_done.assert_not_called()
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": self.packing_location.barcode,
+ "quantity": move_line.product_uom_qty, # 6 qty
+ "confirmation": False,
+ },
+ )
+ self.assertEqual(move_line.state, "done")
# Check picking data (move has been split in two, 6 done and 4 remaining)
- moves_after = self.picking4.move_lines
- self.assertEqual(len(moves_after), 2)
- self.assertEqual(moves_after[0].product_uom_qty, 6)
- self.assertEqual(moves_after[0].state, "done")
- self.assertEqual(moves_after[0].move_line_ids.product_uom_qty, 0)
- self.assertEqual(moves_after[1].product_uom_qty, 4)
- self.assertEqual(moves_after[1].state, "assigned")
- self.assertEqual(moves_after[1].move_line_ids.product_uom_qty, 4)
+
+ done_picking = self.picking4.backorder_ids
+ self.assertEqual(done_picking.state, "done")
+ self.assertEqual(self.picking4.state, "assigned")
+ move_after = self.picking4.move_lines
+ self.assertEqual(len(move_after), 1)
+ self.assertEqual(move_line.move_id.product_uom_qty, 6)
+ self.assertEqual(move_line.move_id.state, "done")
+ self.assertEqual(move_line.move_id.move_line_ids.product_uom_qty, 0)
+ self.assertEqual(move_after.product_uom_qty, 4)
+ self.assertEqual(move_after.state, "assigned")
+ self.assertEqual(move_after.move_line_ids.product_uom_qty, 4)
self.assertEqual(move_line.qty_done, 6)
self.assertNotEqual(move_line.move_id, other_move_line.move_id)
# Check response
@@ -342,19 +340,17 @@ def test_set_destination_location_several_move_line_partial_qty(self):
move_line = moves_before.move_line_ids[0]
# we need a destination package if we want to scan a destination location
move_line.result_package_id = self.free_package
- with mock.patch.object(type(self.picking4), "action_done") as action_done:
- response = self.service.dispatch(
- "set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "move_line_id": move_line.id,
- "barcode": barcode,
- "quantity": 4, # 4/6 qty
- "confirmation": False,
- },
- )
- action_done.assert_not_called()
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "move_line_id": move_line.id,
+ "barcode": barcode,
+ "quantity": 4, # 4/6 qty
+ "confirmation": False,
+ },
+ )
self.assert_response_set_line_destination(
response,
zone_location,
diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py
index 8b29c74687..05c6f8e16e 100644
--- a/shopfloor/tests/test_zone_picking_unload_all.py
+++ b/shopfloor/tests/test_zone_picking_unload_all.py
@@ -1,5 +1,3 @@
-from unittest import mock
-
from .test_zone_picking_base import ZonePickingCommonCase
@@ -197,16 +195,14 @@ def test_set_destination_all_ok(self):
another_package,
)
# set destination location for all lines in the buffer
- with mock.patch.object(type(self.picking5), "action_done") as action_done:
- response = self.service.dispatch(
- "set_destination_all",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "barcode": self.packing_location.barcode,
- },
- )
- action_done.assert_called_once()
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
# check data
self.assertEqual(self.picking5.state, "done")
# buffer should be empty
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
index b1f01a6de8..a27e4356af 100644
--- a/shopfloor/tests/test_zone_picking_unload_set_destination.py
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -1,5 +1,3 @@
-from unittest import mock
-
from .test_zone_picking_base import ZonePickingCommonCase
@@ -223,18 +221,16 @@ def test_unload_set_destination_ok_buffer_empty(self):
move_line.product_uom_qty,
self.free_package,
)
- with mock.patch.object(type(self.picking1), "action_done") as action_done:
- response = self.service.dispatch(
- "unload_set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "package_id": self.free_package.id,
- "barcode": packing_sublocation.barcode,
- "confirmation": True,
- },
- )
- action_done.assert_called_once()
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": self.free_package.id,
+ "barcode": packing_sublocation.barcode,
+ "confirmation": True,
+ },
+ )
# check data
self.assertEqual(move_line.location_dest_id, packing_sublocation)
self.assertEqual(move_line.move_id.state, "done")
@@ -255,8 +251,9 @@ def test_unload_set_destination_ok_buffer_not_empty(self):
self.another_package = self.env["stock.quant.package"].create(
{"name": "ANOTHER_PACKAGE"}
)
+ move_lines = self.picking5.move_line_ids
for move_line, package_dest in zip(
- self.picking5.move_line_ids, self.free_package | self.another_package
+ move_lines, self.free_package | self.another_package
):
self.service._set_destination_package(
zone_location,
@@ -265,24 +262,31 @@ def test_unload_set_destination_ok_buffer_not_empty(self):
move_line.product_uom_qty,
package_dest,
)
- # process 1/2 buffer line
- with mock.patch.object(type(self.picking5), "action_done") as action_done:
- response = self.service.dispatch(
- "unload_set_destination",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "package_id": self.free_package.id,
- "barcode": self.packing_location.barcode,
- },
- )
- action_done.assert_not_called()
- # check data
- move_line = self.picking5.move_line_ids.filtered(
+ free_package_line = move_lines.filtered(
lambda l: l.result_package_id == self.free_package
)
- self.assertEqual(move_line.location_dest_id, self.packing_location)
- self.assertEqual(move_line.move_id.state, "done")
+ another_package_line = move_lines - free_package_line
+
+ # process 1/2 buffer line
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": self.free_package.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ # check data
+ done_picking = self.picking5.backorder_ids
+ self.assertEqual(done_picking.state, "done")
+ self.assertEqual(done_picking.move_line_ids, free_package_line)
+
+ self.assertEqual(free_package_line.location_dest_id, self.packing_location)
+ self.assertEqual(free_package_line.move_id.state, "done")
+
+ self.assertEqual(self.picking5.move_line_ids, another_package_line)
+
# check response
buffer_line = self.service._find_buffer_move_lines(zone_location, picking_type)
completion_info = self.service.actions_for("completion.info")
From 2b8d4afb8de61ef360171bd7c5db504f1b159239 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Thu, 17 Sep 2020 09:37:28 +0200
Subject: [PATCH 370/940] location transfer: extract move in new picking and
use action_done
---
.../services/location_content_transfer.py | 20 +-
...ransfer_set_destination_package_or_line.py | 191 ++++++++++--------
2 files changed, 110 insertions(+), 101 deletions(-)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index a2dfcdd88b..4d28698ff6 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -525,13 +525,6 @@ def set_destination_package(
After the destination is set, the move is set to done.
- Beware, when _action_done() is called on the move, the normal behavior
- of Odoo would be to create a backorder transfer. We don't want this or
- we would have a backorder per move. The context key
- ``_sf_no_backorder`` disables the creation of backorders, it must be set
- on all moves, but the last one of a transfer (so in case something was not
- available, a backorder is created).
-
Transitions:
* scan_destination: invalid destination or could not
* start_single: continue with the next package level / line
@@ -576,7 +569,7 @@ def set_destination_package(
# split the move to process only the lines related to the package.
package_move.split_other_move_lines(package_move_lines)
self._write_destination_on_lines(package_level.move_line_ids, scanned_location)
- package_moves.with_context(_sf_no_backorder=True)._action_done()
+ package_moves.extract_and_action_done()
move_lines = self._find_transfer_move_lines(location)
message = self.msg_store.location_content_transfer_item_complete(
scanned_location
@@ -596,13 +589,6 @@ def set_destination_line(
After the destination and quantity are set, the move is set to done.
- Beware, when _action_done() is called on the move, the normal behavior
- of Odoo would be to create a backorder transfer. We don't want this or
- we would have a backorder per move. The context key
- ``_sf_no_backorder`` disables the creation of backorders, it must be set
- on all moves, but the last one of a transfer (so in case something was not
- available, a backorder is created).
-
Transitions:
* scan_destination: invalid destination or could not
* start_single: continue with the next package level / line
@@ -647,13 +633,13 @@ def set_destination_line(
new_move = self.env["stock.move"].browse(new_move_id)
new_move.move_line_ids = move_line
# Ensure that the remaining qty to process is reserved as before
- current_move._recompute_state()
+ (new_move | current_move)._recompute_state()
(new_move | current_move)._action_assign()
for remaining_move_line in current_move.move_line_ids:
remaining_move_line.qty_done = remaining_move_line.product_uom_qty
move_line.move_id.split_other_move_lines(move_line)
self._write_destination_on_lines(move_line, scanned_location)
- move_line.move_id.with_context(_sf_no_backorder=True)._action_done()
+ move_line.move_id.extract_and_action_done()
move_lines = self._find_transfer_move_lines(location)
message = self.msg_store.location_content_transfer_item_complete(
scanned_location
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
index 0543243704..2e5b9c27aa 100644
--- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
@@ -1,4 +1,5 @@
-from unittest import mock
+# Copyright 2020 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from .test_location_content_transfer_base import LocationContentTransferCommonCase
@@ -153,17 +154,21 @@ def test_set_destination_package_dest_location_to_confirm(self):
def test_set_destination_package_dest_location_ok(self):
"""Scanned destination location valid, moves set to done."""
- package_level = self.picking1.package_level_ids[0]
- with mock.patch.object(type(self.picking1), "action_done") as action_done:
- response = self.service.dispatch(
- "set_destination_package",
- params={
- "location_id": self.content_loc.id,
- "package_level_id": package_level.id,
- "barcode": self.dest_location.barcode,
- },
- )
- action_done.assert_called_once()
+ original_picking = self.picking1
+ package_level = original_picking.package_level_ids[0]
+ response = self.service.dispatch(
+ "set_destination_package",
+ params={
+ "location_id": self.content_loc.id,
+ "package_level_id": package_level.id,
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ # Check the data (the whole transfer has been validated here w/o backorder)
+ self.assertFalse(original_picking.backorder_ids)
+ self.assertEqual(original_picking.state, "done")
+ self.assertEqual(package_level.state, "done")
+ # Check the response
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
response,
@@ -283,7 +288,8 @@ def test_set_destination_line_dest_location_to_confirm(self):
def test_set_destination_line_dest_location_ok(self):
"""Scanned destination location valid, moves set to done."""
- move_line = self.picking2.move_line_ids[0]
+ original_picking = self.picking2
+ move_line = original_picking.move_line_ids[0]
response = self.service.dispatch(
"set_destination_line",
params={
@@ -293,6 +299,15 @@ def test_set_destination_line_dest_location_ok(self):
"barcode": self.dest_location.barcode,
},
)
+ # Check the resulting data
+ # We got a new picking as the original one had two moves (and we
+ # validated only one)
+ new_picking = move_line.picking_id
+ self.assertTrue(new_picking != original_picking)
+ self.assertEqual(move_line.move_id.state, "done")
+ self.assertEqual(move_line.picking_id.state, "done")
+ self.assertEqual(original_picking.state, "assigned")
+ # Check the response
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
response,
@@ -301,14 +316,13 @@ def test_set_destination_line_dest_location_ok(self):
self.dest_location
),
)
- self.assertEqual(move_line.move_id.state, "done")
- self.assertEqual(move_line.picking_id.state, "assigned")
def test_set_destination_line_partial_qty(self):
"""Scanned destination location with partial qty, but related moves
has to be splitted.
"""
- move_line_c = self.picking2.move_line_ids.filtered(
+ original_picking = self.picking2
+ move_line_c = original_picking.move_line_ids.filtered(
lambda m: m.product_id == self.product_c
)
move = move_line_c.move_id
@@ -324,12 +338,16 @@ def test_set_destination_line_partial_qty(self):
"barcode": self.dest_location.barcode,
},
)
+ done_picking = original_picking.backorder_ids
# Check move line data
self.assertEqual(move_line_c.move_id.product_uom_qty, 6)
self.assertEqual(move_line_c.product_uom_qty, 0)
self.assertEqual(move_line_c.qty_done, 6)
self.assertEqual(move_line_c.state, "done")
+ self.assertEqual(original_picking.backorder_ids, done_picking)
+ self.assertEqual(done_picking.state, "done")
# the move is split with the remaining
+ self.assertEqual(original_picking.state, "assigned")
self.assertEqual(move.state, "assigned")
self.assertEqual(move.product_id, self.product_c)
self.assertEqual(move.product_uom_qty, 4)
@@ -347,24 +365,27 @@ def test_set_destination_line_partial_qty(self):
self.assertEqual(move_line_c.move_id.state, "done")
# Scan remaining qty (4/10)
remaining_move_line_c = move.move_line_ids
- with mock.patch.object(type(self.picking2), "action_done") as action_done:
- response = self.service.dispatch(
- "set_destination_line",
- params={
- "location_id": self.content_loc.id,
- "move_line_id": remaining_move_line_c.id,
- "quantity": remaining_move_line_c.product_uom_qty,
- "barcode": self.dest_location.barcode,
- },
- )
- action_done.assert_not_called()
+ response = self.service.dispatch(
+ "set_destination_line",
+ params={
+ "location_id": self.content_loc.id,
+ "move_line_id": remaining_move_line_c.id,
+ "quantity": remaining_move_line_c.product_uom_qty,
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ done_picking2 = remaining_move_line_c.picking_id
# Check move line data
self.assertEqual(remaining_move_line_c.move_id.product_uom_qty, 4)
self.assertEqual(remaining_move_line_c.product_uom_qty, 0)
self.assertEqual(remaining_move_line_c.qty_done, 4)
self.assertEqual(remaining_move_line_c.state, "done")
- # All move lines related to product_c are now done
- moves_product_c = self.picking2.move_lines.filtered(
+ self.assertTrue(done_picking2 != original_picking)
+ self.assertEqual(done_picking2.state, "done")
+ # All move lines related to product_c are now done and extracted from
+ # the initial transfer
+ all_pickings = original_picking | done_picking | done_picking2
+ moves_product_c = all_pickings.move_lines.filtered(
lambda m: m.product_id == self.product_c
)
moves_product_c_done = all(move.state == "done" for move in moves_product_c)
@@ -372,27 +393,25 @@ def test_set_destination_line_partial_qty(self):
moves_product_c_qty_done = sum([move.quantity_done for move in moves_product_c])
self.assertEqual(moves_product_c_qty_done, 10)
# The picking is still not done as product_d hasn't been processed
- self.assertEqual(self.picking2.state, "assigned")
+ self.assertEqual(original_picking.state, "assigned")
# Let scan product_d quantity and check picking state
- move_line_d = self.picking2.move_line_ids.filtered(
+ move_line_d = original_picking.move_line_ids.filtered(
lambda m: m.product_id == self.product_d
)
- with mock.patch.object(type(self.picking2), "action_done") as action_done:
- response = self.service.dispatch(
- "set_destination_line",
- params={
- "location_id": self.content_loc.id,
- "move_line_id": move_line_d.id,
- "quantity": move_line_d.product_uom_qty,
- "barcode": self.dest_location.barcode,
- },
- )
- self.assertEqual(move_line_d.move_id.product_uom_qty, 10)
- self.assertEqual(move_line_d.product_uom_qty, 0)
- self.assertEqual(move_line_d.qty_done, 10)
- self.assertEqual(move_line_d.state, "done")
- self.assertEqual(self.picking2.state, "done")
- action_done.assert_called_once()
+ response = self.service.dispatch(
+ "set_destination_line",
+ params={
+ "location_id": self.content_loc.id,
+ "move_line_id": move_line_d.id,
+ "quantity": move_line_d.product_uom_qty,
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ self.assertEqual(move_line_d.move_id.product_uom_qty, 10)
+ self.assertEqual(move_line_d.product_uom_qty, 0)
+ self.assertEqual(move_line_d.qty_done, 10)
+ self.assertEqual(move_line_d.state, "done")
+ self.assertEqual(original_picking.state, "done")
class LocationContentTransferSetDestinationXSpecialCase(
@@ -461,9 +480,10 @@ def test_set_destination_package_split_move(self):
"""Scanned destination location valid for a package, but related moves
has to be splitted because it is linked to additional move lines.
"""
- self.assertEqual(len(self.picking.move_lines), 2)
+ original_picking = self.picking
+ self.assertEqual(len(original_picking.move_lines), 2)
self.assertEqual(len(self.move_product_a.move_line_ids), 2)
- package_level = self.picking.package_level_ids[0]
+ package_level = original_picking.package_level_ids[0]
response = self.service.dispatch(
"set_destination_package",
params={
@@ -472,22 +492,24 @@ def test_set_destination_package_split_move(self):
"barcode": self.dest_location.barcode,
},
)
+ done_picking = package_level.picking_id
# Check the picking data
+ self.assertEqual(original_picking.backorder_ids, done_picking)
self.assertEqual(package_level.location_dest_id, self.dest_location)
for move_line in package_level.move_line_ids:
self.assertEqual(move_line.location_dest_id, self.dest_location)
- moves_product_a = self.picking.move_lines.filtered(
+ moves_product_a = original_picking.move_lines.filtered(
lambda m: m.product_id == self.product_a
)
- self.assertEqual(len(self.picking.move_lines), 3)
- self.assertEqual(len(moves_product_a), 2)
+ self.assertEqual(len(original_picking.move_lines), 2)
+ self.assertEqual(len(moves_product_a), 1)
for move in moves_product_a:
self.assertEqual(len(move.move_line_ids), 1)
- move_lines_wo_pkg = self.picking.move_line_ids_without_package
+ move_lines_wo_pkg = original_picking.move_line_ids_without_package
move_lines_wo_pkg_states = set(move_lines_wo_pkg.mapped("state"))
self.assertEqual(len(move_lines_wo_pkg_states), 1)
self.assertEqual(move_lines_wo_pkg_states.pop(), "assigned")
- self.assertEqual(self.picking.package_level_ids.state, "done")
+ self.assertEqual(done_picking.package_level_ids.state, "done")
# Check the response
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
@@ -502,7 +524,8 @@ def test_set_destination_line_split_move(self):
"""Scanned destination location valid for a move line, but related moves
has to be splitted because it is linked to additional move lines.
"""
- self.assertEqual(len(self.picking.move_lines), 2)
+ original_picking = self.picking
+ self.assertEqual(len(original_picking.move_lines), 2)
self.assertEqual(len(self.move_product_b.move_line_ids), 2)
move_line = self.move_product_b.move_line_ids.filtered(
lambda ml: ml.product_uom_qty == 6
@@ -516,26 +539,28 @@ def test_set_destination_line_split_move(self):
"barcode": self.dest_location.barcode,
},
)
+ done_picking = move_line.picking_id
# Check the picking data
- self.assertEqual(self.picking.state, "assigned")
+ self.assertEqual(original_picking.backorder_ids, done_picking)
+ self.assertEqual(done_picking.state, "done")
+ self.assertEqual(original_picking.state, "assigned")
self.assertEqual(move_line.move_id.product_uom_qty, 6)
self.assertEqual(move_line.product_uom_qty, 0)
self.assertEqual(move_line.qty_done, 6)
self.assertEqual(move_line.location_dest_id, self.dest_location)
- moves_product_b = self.picking.move_lines.filtered(
+ self.assertEqual(len(original_picking.move_lines), 2)
+ moves_product_b = original_picking.move_lines.filtered(
lambda m: m.product_id == self.product_b
)
- self.assertEqual(len(self.picking.move_lines), 3)
- self.assertEqual(len(moves_product_b), 2)
+ self.assertEqual(len(moves_product_b), 1)
for move in moves_product_b:
self.assertEqual(len(move.move_line_ids), 1)
- move_lines_wo_pkg = self.picking.move_line_ids_without_package
+ move_lines_wo_pkg = original_picking.move_line_ids_without_package
move_lines_wo_pkg_states = set(move_lines_wo_pkg.mapped("state"))
- self.assertEqual(len(move_lines_wo_pkg_states), 2)
- self.assertIn("assigned", move_lines_wo_pkg_states)
- self.assertIn("done", move_lines_wo_pkg_states)
+ self.assertEqual(len(move_lines_wo_pkg_states), 1)
+ self.assertTrue(all(state == "assigned" for state in move_lines_wo_pkg_states))
self.assertEqual(move_line.state, "done")
- remaining_move = self.picking.move_lines.filtered(
+ remaining_move = original_picking.move_lines.filtered(
lambda m: move_line.move_id != m and m.product_id == self.product_b
)
self.assertEqual(remaining_move.state, "assigned")
@@ -553,34 +578,30 @@ def test_set_destination_line_split_move(self):
)
# Process the other move lines (lines w/o package + package levels)
# to check the picking state
- remaining_move_lines = self.picking.move_line_ids_without_package.filtered(
+ remaining_move_lines = original_picking.move_line_ids_without_package.filtered(
lambda ml: ml.state == "assigned"
)
- with mock.patch.object(type(self.picking), "action_done") as action_done:
- for ml in remaining_move_lines:
- self.service.dispatch(
- "set_destination_line",
- params={
- "location_id": self.content_loc.id,
- "move_line_id": ml.id,
- "quantity": ml.product_uom_qty,
- "barcode": self.dest_location.barcode,
- },
- )
- self.assertEqual(self.picking.state, "assigned")
- action_done.assert_not_called()
- package_level = self.picking.package_level_ids[0]
- with mock.patch.object(type(self.picking), "action_done") as action_done:
+ for ml in remaining_move_lines:
self.service.dispatch(
- "set_destination_package",
+ "set_destination_line",
params={
"location_id": self.content_loc.id,
- "package_level_id": package_level.id,
+ "move_line_id": ml.id,
+ "quantity": ml.product_uom_qty,
"barcode": self.dest_location.barcode,
},
)
- self.assertEqual(self.picking.state, "done")
- action_done.assert_called_once()
+ self.assertEqual(original_picking.state, "assigned")
+ package_level = original_picking.package_level_ids[0]
+ self.service.dispatch(
+ "set_destination_package",
+ params={
+ "location_id": self.content_loc.id,
+ "package_level_id": package_level.id,
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ self.assertEqual(original_picking.state, "done")
class LocationContentTransferSetDestinationChainSpecialCase(
@@ -654,7 +675,9 @@ def test_set_destination_line_partial_qty_with_move_orig_ids(self):
"barcode": self.dest_location.barcode,
},
)
+ done_picking = move_line_c.picking_id
# Check move line data
+ self.assertEqual(picking_b.backorder_ids, done_picking)
self.assertEqual(move_line_c.move_id.product_uom_qty, 6)
self.assertEqual(move_line_c.product_uom_qty, 0)
self.assertEqual(move_line_c.qty_done, 6)
From f220bfa804cd63dc2238c11f7a10019e0f9991e0 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Thu, 17 Sep 2020 17:21:45 +0200
Subject: [PATCH 371/940] backend: remove code related to _action_done usage
Calling _action_done() directly on the stock.move while leaving the
stock.picking "assigned" with the other moves already required various
workarounds as it is normally not an expected flow in Odoo.
We have detected new issues with package levels which alter "done" move
lines when we call _action_assign() on the other moves again.
Previous commits removed the calls to _action_done() on the moves and
replaced them by a method that extract the move to set to done in a new
stock.picking (StockMove.extract_and_action_done()).
This commit removes the hacks which are no longer used.
---
shopfloor/models/stock_move.py | 11 -----------
shopfloor/models/stock_picking.py | 10 ----------
shopfloor/tests/test_single_pack_transfer.py | 2 +-
shopfloor/tests/test_stock_split.py | 2 +-
4 files changed, 2 insertions(+), 23 deletions(-)
diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py
index da24e18d02..f1690dd182 100644
--- a/shopfloor/models/stock_move.py
+++ b/shopfloor/models/stock_move.py
@@ -26,17 +26,6 @@ def split_other_move_lines(self, move_lines, intersection=False):
return backorder_move
return False
- def _action_done(self, cancel_backorder=False):
- # Overloaded to ensure that the 'action_done' method of the picking
- # is called when the last move of a picking is validated.
- moves = super()._action_done(cancel_backorder)
- if not self.env.context.get("_action_done_from_picking"):
- pickings = moves.picking_id
- for picking in pickings:
- if picking.state == "done":
- picking.action_done()
- return moves
-
def extract_and_action_done(self):
"""Extract the moves in a separate transfer and validate them.
diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py
index 7f1714e374..5f88110457 100644
--- a/shopfloor/models/stock_picking.py
+++ b/shopfloor/models/stock_picking.py
@@ -36,13 +36,3 @@ def _calc_weight(self):
for move_line in self.mapped("move_line_ids"):
weight += move_line.product_qty * move_line.product_id.weight
return weight
-
- def _create_backorder(self):
- if self.env.context.get("_sf_no_backorder"):
- return self.browse()
- else:
- return super()._create_backorder()
-
- def action_done(self):
- self = self.with_context(_action_done_from_picking=True)
- return super().action_done()
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 298ab080c2..041aa51b56 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -835,7 +835,7 @@ def test_cancel_already_done(self):
picking = move.picking_id
# someone cancel the work started by our operator
- move._action_done()
+ move.extract_and_action_done()
# now, call the service to cancel
response = self.service.dispatch(
diff --git a/shopfloor/tests/test_stock_split.py b/shopfloor/tests/test_stock_split.py
index b39e22878f..a0554d6ea1 100644
--- a/shopfloor/tests/test_stock_split.py
+++ b/shopfloor/tests/test_stock_split.py
@@ -145,7 +145,7 @@ def test_split_pickings_from_source_location(self):
move_line.qty_done = move_line.product_uom_qty
if i % 2:
move_line.location_dest_id = dest_location
- self.pick_move_a.with_context(_sf_no_backorder=True)._action_done()
+ self.pick_move_a.extract_and_action_done()
self.assertEqual(self.pick_move_a.state, "done")
# Pack step, we want to split move lines from common source location
self.assertEqual(self.pack_move_a.state, "assigned")
From 64e4168a2c8bdf290eec0159b265e717ba712c61 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Mon, 21 Sep 2020 15:36:46 +0200
Subject: [PATCH 372/940] location content transfer: add completion info
Each time a good has been moved (on `set_destination_all`,
`set_destination_package` or `set_destination_line`), completion info is returned.
---
.../services/location_content_transfer.py | 26 +++--
.../test_location_content_transfer_base.py | 9 +-
...on_content_transfer_set_destination_all.py | 42 ++++++++
...ransfer_set_destination_package_or_line.py | 97 +++++++++++++++++++
4 files changed, 164 insertions(+), 10 deletions(-)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 4d28698ff6..f9ebfc3d1d 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -47,9 +47,9 @@ class LocationContentTransfer(Component):
_usage = "location_content_transfer"
_description = __doc__
- def _response_for_start(self, message=None):
+ def _response_for_start(self, message=None, popup=None):
"""Transition to the 'start' state"""
- return self._response(next_state="start", message=message)
+ return self._response(next_state="start", message=message, popup=popup)
def _response_for_scan_destination_all(
self, pickings, message=None, confirmation_required=False
@@ -70,7 +70,7 @@ def _response_for_scan_destination_all(
next_state="scan_destination_all", data=data, message=message
)
- def _response_for_start_single(self, pickings, message=None):
+ def _response_for_start_single(self, pickings, message=None, popup=None):
"""Transition to the 'start_single' state
The client screen shows details of the package level or move line to move.
@@ -79,11 +79,12 @@ def _response_for_start_single(self, pickings, message=None):
next_content = self._next_content(pickings)
if not next_content:
# TODO test (no more lines)
- return self._response_for_start(message=message)
+ return self._response_for_start(message=message, popup=popup)
return self._response(
next_state="start_single",
data=self._data_content_line_for_location(location, next_content),
message=message,
+ popup=popup,
)
def _response_for_scan_destination(
@@ -384,10 +385,13 @@ def set_destination_all(self, location_id, barcode, confirmation=False):
self._set_all_destination_lines_and_done(pickings, move_lines, scanned_location)
+ completion_info = self.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(move_lines)
return self._response_for_start(
message=self.msg_store.location_content_transfer_complete(
location, scanned_location
- )
+ ),
+ popup=completion_info_popup,
)
def go_to_single(self, location_id):
@@ -574,8 +578,12 @@ def set_destination_package(
message = self.msg_store.location_content_transfer_item_complete(
scanned_location
)
+ completion_info = self.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(package_moves.move_line_ids)
return self._response_for_start_single(
- move_lines.mapped("picking_id"), message=message
+ move_lines.mapped("picking_id"),
+ message=message,
+ popup=completion_info_popup,
)
def set_destination_line(
@@ -644,8 +652,12 @@ def set_destination_line(
message = self.msg_store.location_content_transfer_item_complete(
scanned_location
)
+ completion_info = self.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(move_line)
return self._response_for_start_single(
- move_lines.mapped("picking_id"), message=message
+ move_lines.mapped("picking_id"),
+ message=message,
+ popup=completion_info_popup,
)
def postpone_package(self, location_id, package_level_id):
diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py
index 6c89326b34..40e24385c5 100644
--- a/shopfloor/tests/test_location_content_transfer_base.py
+++ b/shopfloor/tests/test_location_content_transfer_base.py
@@ -46,8 +46,8 @@ def _simulate_pickings_selected(cls, pickings):
for line in pickings.mapped("move_line_ids"):
line.qty_done = line.product_uom_qty
- def assert_response_start(self, response, message=None):
- self.assert_response(response, next_state="start", message=message)
+ def assert_response_start(self, response, message=None, popup=None):
+ self.assert_response(response, next_state="start", message=message, popup=popup)
def _assert_response_scan_destination_all(
self, state, response, pickings, message=None, confirmation_required=False
@@ -81,7 +81,9 @@ def assert_response_scan_destination_all(
confirmation_required=confirmation_required,
)
- def assert_response_start_single(self, response, pickings, message=None):
+ def assert_response_start_single(
+ self, response, pickings, message=None, popup=None
+ ):
sorter = self.service.actions_for("location_content_transfer.sorter")
sorter.feed_pickings(pickings)
location = pickings.mapped("location_id")
@@ -90,6 +92,7 @@ def assert_response_start_single(self, response, pickings, message=None):
next_state="start_single",
data=self.service._data_content_line_for_location(location, next(sorter)),
message=message,
+ popup=popup,
)
def _assert_response_scan_destination(
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py
index fc33a4d742..93cd3d80bc 100644
--- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py
@@ -78,6 +78,48 @@ def test_set_destination_all_dest_location_ok(self):
)
self.assert_all_done(sub_shelf1)
+ def test_set_destination_all_dest_location_ok_with_completion_info(self):
+ """Scanned destination location valid, moves set to done accepted
+ and completion info is returned as the next transfer is ready.
+ """
+ move = self.picking1.move_lines[0]
+ next_move = move.copy(
+ {
+ "location_id": move.location_dest_id.id,
+ "location_dest_id": self.customer_location.id,
+ "move_orig_ids": [(6, 0, move.ids)],
+ }
+ )
+ next_move._action_confirm(merge=False)
+ next_move._assign_picking()
+ self.assertEqual(next_move.state, "waiting")
+ sub_shelf1 = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Sub Shelf 1",
+ "barcode": "subshelf1",
+ "location_id": self.shelf1.id,
+ }
+ )
+ )
+ move_lines = self.service._find_transfer_move_lines(self.content_loc)
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={"location_id": self.content_loc.id, "barcode": sub_shelf1.barcode},
+ )
+ self.assertEqual(next_move.state, "assigned")
+ completion_info = self.service.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(move_lines)
+ self.assert_response_start(
+ response,
+ message=self.service.msg_store.location_content_transfer_complete(
+ self.content_loc, sub_shelf1
+ ),
+ popup=completion_info_popup,
+ )
+
def test_set_destination_all_dest_location_not_found(self):
"""Barcode scanned for destination location is not found"""
response = self.service.dispatch(
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
index 2e5b9c27aa..6212307b49 100644
--- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
@@ -180,6 +180,53 @@ def test_set_destination_package_dest_location_ok(self):
for move in package_level.move_line_ids.mapped("move_id"):
self.assertEqual(move.state, "done")
+ def test_set_destination_package_dest_location_ok_with_completion_info(self):
+ """Scanned destination location valid, moves set to done
+ and completion info is returned as the next transfer is ready.
+ """
+ original_picking = self.picking1
+ package_level = original_picking.package_level_ids[0]
+ move = package_level.move_line_ids.move_id[0]
+ next_move = move.copy(
+ {
+ "picking_id": False,
+ "location_id": move.location_dest_id.id,
+ "location_dest_id": self.customer_location.id,
+ "move_orig_ids": [(6, 0, move.ids)],
+ }
+ )
+ next_move._action_confirm(merge=False)
+ next_move._assign_picking()
+ self.assertEqual(next_move.state, "waiting")
+ self.assertTrue(next_move.picking_id)
+ response = self.service.dispatch(
+ "set_destination_package",
+ params={
+ "location_id": self.content_loc.id,
+ "package_level_id": package_level.id,
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ # Check the data (the whole transfer has been validated here w/o backorder)
+ self.assertFalse(original_picking.backorder_ids)
+ self.assertEqual(original_picking.state, "done")
+ self.assertEqual(package_level.state, "done")
+ self.assertEqual(next_move.state, "assigned")
+ # Check the response
+ move_lines = self.service._find_transfer_move_lines(self.content_loc)
+ completion_info = self.service.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(package_level.move_line_ids)
+ self.assert_response_start_single(
+ response,
+ move_lines.mapped("picking_id"),
+ message=self.service.msg_store.location_content_transfer_item_complete(
+ self.dest_location
+ ),
+ popup=completion_info_popup,
+ )
+ for move in package_level.move_line_ids.mapped("move_id"):
+ self.assertEqual(move.state, "done")
+
def test_set_destination_line_wrong_parameters(self):
"""Wrong 'location' and 'move_line_id' parameters, redirect the
user to the 'start' screen.
@@ -317,6 +364,56 @@ def test_set_destination_line_dest_location_ok(self):
),
)
+ def test_set_destination_line_dest_location_ok_with_completion_info(self):
+ """Scanned destination location valid, moves set to done
+ and completion info is returned as the next transfer is ready.
+ """
+ original_picking = self.picking2
+ move_line = original_picking.move_line_ids[0]
+ move = move_line.move_id
+ next_move = move.copy(
+ {
+ "picking_id": False,
+ "location_id": move.location_dest_id.id,
+ "location_dest_id": self.customer_location.id,
+ "move_orig_ids": [(6, 0, move.ids)],
+ }
+ )
+ next_move._action_confirm(merge=False)
+ next_move._assign_picking()
+ self.assertEqual(next_move.state, "waiting")
+ self.assertTrue(next_move.picking_id)
+ response = self.service.dispatch(
+ "set_destination_line",
+ params={
+ "location_id": self.content_loc.id,
+ "move_line_id": move_line.id,
+ "quantity": move_line.product_uom_qty,
+ "barcode": self.dest_location.barcode,
+ },
+ )
+ # Check the resulting data
+ # We got a new picking as the original one had two moves (and we
+ # validated only one)
+ new_picking = move_line.picking_id
+ self.assertTrue(new_picking != original_picking)
+ self.assertEqual(move_line.move_id.state, "done")
+ self.assertEqual(move_line.picking_id.state, "done")
+ self.assertEqual(original_picking.state, "assigned")
+ self.assertEqual(next_move.state, "assigned")
+ # Check the response
+ move_lines = self.service._find_transfer_move_lines(self.content_loc)
+ completion_info = self.service.actions_for("completion.info")
+ completion_info_popup = completion_info.popup(move_line)
+ self.assert_response_start_single(
+ response,
+ move_lines.mapped("picking_id"),
+ message=self.service.msg_store.location_content_transfer_item_complete(
+ self.dest_location
+ ),
+ popup=completion_info_popup,
+ )
+
def test_set_destination_line_partial_qty(self):
"""Scanned destination location with partial qty, but related moves
has to be splitted.
From 4122e1155ff3b40317cd615b9f6c70aa3cbaf67b Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 22 Sep 2020 08:42:41 +0200
Subject: [PATCH 373/940] backend: change "postponed" as "priority" (#80)
Postponed becomes a priority value of 9999.
Default priority is 10.
We will use a lower value for priority, for instance, to force
some move lines to be processed "right now" after we split a package
level.
---
shopfloor/__manifest__.py | 2 +-
.../location_content_transfer_sorter.py | 2 +-
.../migrations/13.0.1.1.0/pre-migration.py | 77 +++++++++++++++++++
shopfloor/models/__init__.py | 1 +
shopfloor/models/priority_postpone_mixin.py | 38 +++++++++
shopfloor/models/stock_move_line.py | 9 +--
shopfloor/models/stock_package_level.py | 12 +--
shopfloor/services/cluster_picking.py | 2 +-
shopfloor/views/stock_move_line.xml | 4 +-
9 files changed, 126 insertions(+), 21 deletions(-)
create mode 100644 shopfloor/migrations/13.0.1.1.0/pre-migration.py
create mode 100644 shopfloor/models/priority_postpone_mixin.py
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 4412ed12e6..b1d12b54fb 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -4,7 +4,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.0.0",
+ "version": "13.0.1.1.0",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://odoo-community.org",
diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py
index 1878e45fe8..94d449aa68 100644
--- a/shopfloor/actions/location_content_transfer_sorter.py
+++ b/shopfloor/actions/location_content_transfer_sorter.py
@@ -33,7 +33,7 @@ def _sort_key(content):
# level
return (
# postponed content after other contents
- int(content.shopfloor_postponed),
+ content.shopfloor_priority or 10,
# sort by shopfloor picking sequence
content.location_dest_id.shopfloor_picking_sequence or "",
# sort by similar destination
diff --git a/shopfloor/migrations/13.0.1.1.0/pre-migration.py b/shopfloor/migrations/13.0.1.1.0/pre-migration.py
new file mode 100644
index 0000000000..8255f2c535
--- /dev/null
+++ b/shopfloor/migrations/13.0.1.1.0/pre-migration.py
@@ -0,0 +1,77 @@
+from psycopg2 import sql
+
+from odoo.tools import column_exists
+
+
+def migrate(cr, version):
+ renames = (
+ ("stock.move.line", "shopfloor_postponed", "shopfloor_priority"),
+ ("stock.package.level", "shopfloor_postponed", "shopfloor_priority"),
+ )
+ for model, old_field, new_field in renames:
+ table = model.replace(".", "_")
+ if not column_exists(cr, table, old_field):
+ continue
+ # pylint: disable=sql-injection
+ cr.execute(
+ sql.SQL(
+ """
+ ALTER TABLE {}
+ ALTER {}
+ TYPE INTEGER
+ USING CASE COALESCE({}, false)
+ WHEN false THEN 10
+ ELSE 9999
+ END;
+ """
+ ).format(
+ sql.Identifier(table),
+ sql.Identifier(old_field),
+ sql.Identifier(old_field),
+ )
+ )
+ # pylint: disable=sql-injection
+ cr.execute(
+ sql.SQL(
+ """
+ ALTER TABLE {}
+ RENAME {}
+ TO {};
+ """
+ ).format(
+ sql.Identifier(table),
+ sql.Identifier(old_field),
+ sql.Identifier(new_field),
+ )
+ )
+ cr.execute(
+ """
+ UPDATE ir_model_fields
+ SET name = %s
+ WHERE name = %s
+ AND model = %s
+ """,
+ (new_field, old_field, model),
+ )
+ cr.execute(
+ """
+ UPDATE ir_model_data
+ SET name = %s
+ WHERE name = %s
+ AND model = %s
+ """,
+ (
+ "field_{}__{}".format(table, new_field),
+ "field_{}__{}".format(table, old_field),
+ model,
+ ),
+ )
+ cr.execute(
+ """
+ UPDATE ir_translation
+ SET name = %s
+ WHERE name = %s
+ AND type = 'model'
+ """,
+ ("{},{}".format(model, new_field), "{},{}".format(model, old_field)),
+ )
diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py
index cb9e4bf257..30930739fd 100644
--- a/shopfloor/models/__init__.py
+++ b/shopfloor/models/__init__.py
@@ -1,3 +1,4 @@
+from . import priority_postpone_mixin
from . import res_partner
from . import shopfloor_menu
from . import shopfloor_log
diff --git a/shopfloor/models/priority_postpone_mixin.py b/shopfloor/models/priority_postpone_mixin.py
new file mode 100644
index 0000000000..41fa877961
--- /dev/null
+++ b/shopfloor/models/priority_postpone_mixin.py
@@ -0,0 +1,38 @@
+from odoo import api, fields, models
+
+
+class PriorityPostponeMixin(models.AbstractModel):
+ _name = "shopfloor.priority.postpone.mixin"
+ _description = "Adds shopfloor priority/postpone fields"
+
+ # shopfloor_priority is set to this value when postponed
+ # consider it as the max value for priority
+ _SF_PRIORITY_POSTPONED = 9999
+ _SF_PRIORITY_DEFAULT = 10
+
+ shopfloor_priority = fields.Integer(
+ default=lambda self: self._SF_PRIORITY_DEFAULT,
+ copy=False,
+ help="Technical field. Overrides operation priority in barcode scenario.",
+ )
+
+ shopfloor_postponed = fields.Boolean(
+ compute="_compute_shopfloor_postponed",
+ inverse="_inverse_shopfloor_postponed",
+ help="Technical field. "
+ "Indicates if the operation has been postponed in a barcode scenario.",
+ )
+
+ @api.depends("shopfloor_priority")
+ def _compute_shopfloor_postponed(self):
+ for record in self:
+ record.shopfloor_postponed = bool(
+ record.shopfloor_priority == self._SF_PRIORITY_POSTPONED
+ )
+
+ def _inverse_shopfloor_postponed(self):
+ for record in self:
+ if record.shopfloor_postponed:
+ record.shopfloor_priority = self._SF_PRIORITY_POSTPONED
+ else:
+ record.shopfloor_priority = self._SF_PRIORITY_DEFAULT
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index fbc051f8ee..8028b5cff0 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -4,16 +4,11 @@
class StockMoveLine(models.Model):
- _inherit = "stock.move.line"
+ _name = "stock.move.line"
+ _inherit = ["stock.move.line", "shopfloor.priority.postpone.mixin"]
# TODO use a serialized field
shopfloor_unloaded = fields.Boolean(default=False)
- shopfloor_postponed = fields.Boolean(
- default=False,
- copy=False,
- help="Technical field. "
- "Indicates if a the move has been postponed in a barcode scenario.",
- )
shopfloor_checkout_done = fields.Boolean(default=False)
shopfloor_user_id = fields.Many2one(comodel_name="res.users", index=True)
diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py
index 1aa691c6e9..24b1b8a531 100644
--- a/shopfloor/models/stock_package_level.py
+++ b/shopfloor/models/stock_package_level.py
@@ -1,15 +1,9 @@
-from odoo import fields, models
+from odoo import models
class StockPackageLevel(models.Model):
- _inherit = "stock.package_level"
-
- shopfloor_postponed = fields.Boolean(
- default=False,
- copy=False,
- help="Technical field. "
- "Indicates if a the package level has been postponed in a barcode scenario.",
- )
+ _name = "stock.package_level"
+ _inherit = ["stock.package_level", "shopfloor.priority.postpone.mixin"]
def replace_package(self, new_package):
"""Replace a package on an assigned package level and related records
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 3ba0e10066..0041c5af23 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -318,7 +318,7 @@ def _pick_next_line(self, batch, message=None, force_line=None):
@staticmethod
def _sort_key_lines(line):
return (
- line.shopfloor_postponed,
+ line.shopfloor_priority or 10,
line.location_id.shopfloor_picking_sequence or "",
line.location_id.name,
-int(line.move_id.priority or 1),
diff --git a/shopfloor/views/stock_move_line.xml b/shopfloor/views/stock_move_line.xml
index 67ee61d002..348a0fdb93 100644
--- a/shopfloor/views/stock_move_line.xml
+++ b/shopfloor/views/stock_move_line.xml
@@ -24,8 +24,8 @@
groups="base.group_no_one"
/>
From c93509bf05bc2c0f16f37bf0c46d992090d68f15 Mon Sep 17 00:00:00 2001
From: sebalix
Date: Thu, 17 Sep 2020 19:34:27 +0200
Subject: [PATCH 374/940] location transfer: new endpoint to remove a package
level
This allows the user to explode the content of a package and move its
goods to different locations.
Afterwards this means the user will process directly move lines instead
of a package.
---
.../location_content_transfer_sorter.py | 31 ++++++++++++-
shopfloor/actions/message.py | 6 +++
.../services/location_content_transfer.py | 44 ++++++++++++++++++
.../test_location_content_transfer_single.py | 46 +++++++++++++++++++
4 files changed, 125 insertions(+), 2 deletions(-)
diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py
index 94d449aa68..49b45430dc 100644
--- a/shopfloor/actions/location_content_transfer_sorter.py
+++ b/shopfloor/actions/location_content_transfer_sorter.py
@@ -16,15 +16,42 @@ def feed_pickings(self, pickings):
self._pickings |= pickings
def move_lines(self):
- return self._pickings.move_line_ids.filtered(
- # lines without package level only (raw products)
+ """Returns valid move lines.
+
+ Valid move lines are:
+ - those not bound to a package level
+ - those bound to invalid package levels
+
+ An invalid package level has one of its line not targetting the
+ expected package.
+ """
+ # lines without package level only (raw products)
+ move_lines = self._pickings.move_line_ids.filtered(
lambda line: not line.package_level_id
and line.state not in ("cancel", "done")
)
+ # lines with invalid package levels
+ invalid_levels = self._pickings.package_level_ids.filtered(
+ lambda level: level.state not in ("cancel", "done")
+ and any(
+ line.result_package_id != level.package_id
+ for line in level.move_line_ids
+ )
+ )
+ return move_lines | invalid_levels.move_line_ids
def package_levels(self):
+ """Returns valid package levels.
+
+ A valid package level has all its related move lines targetting
+ the expected package.
+ """
return self._pickings.package_level_ids.filtered(
lambda level: level.state not in ("cancel", "done")
+ and all(
+ line.result_package_id == level.package_id
+ for line in level.move_line_ids
+ )
)
@staticmethod
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 51dedf614c..00724daf90 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -459,3 +459,9 @@ def new_move_lines_not_assigned(self):
"message_type": "error",
"body": _("New move lines cannot be assigned: canceled."),
}
+
+ def package_open(self):
+ return {
+ "message_type": "info",
+ "body": _("Package has been opened. You can move partial quantities."),
+ }
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index f9ebfc3d1d..3e3e34cd9c 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -788,6 +788,41 @@ def stock_out_line(self, location_id, move_line_id):
move_lines = self._find_transfer_move_lines(location)
return self._response_for_start_single(move_lines.mapped("picking_id"))
+ def dismiss_package_level(self, location_id, package_level_id):
+ """Dismiss the package level.
+
+ The result package of the related move lines is unset, then the package
+ level itself is removed from the picking. This allows to move parts
+ of the package to different locations.
+
+ The user is then redirected to process the next line of the related picking.
+
+ Transitions:
+ * start_single: continue with the next line
+ """
+ location = self.env["stock.location"].browse(location_id)
+ if not location.exists():
+ return self._response_for_start(message=self.msg_store.record_not_found())
+ package_level = self.env["stock.package_level"].browse(package_level_id)
+ if not package_level.exists():
+ move_lines = self._find_transfer_move_lines(location)
+ return self._response_for_start_single(
+ move_lines.mapped("picking_id"),
+ message=self.msg_store.record_not_found(),
+ )
+ move_lines = package_level.move_line_ids
+ move_lines.write(
+ {
+ "result_package_id": False,
+ # ensure all the lines in the package are the next ones to be processed
+ "shopfloor_priority": 1,
+ }
+ )
+ package_level.unlink()
+ return self._response_for_start_single(
+ move_lines.mapped("picking_id"), message=self.msg_store.package_open()
+ )
+
class ShopfloorLocationContentTransferValidator(Component):
"""Validators for the Location Content Transfer endpoints"""
@@ -867,6 +902,12 @@ def stock_out_line(self):
"move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
}
+ def dismiss_package_level(self):
+ return {
+ "location_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "package_level_id": {"coerce": to_int, "required": True, "type": "integer"},
+ }
+
class ShopfloorLocationContentTransferValidatorResponse(Component):
"""Validators for the Location Content Transfer endpoints responses"""
@@ -963,3 +1004,6 @@ def stock_out_package(self):
def stock_out_line(self):
return self._response_schema(next_states={"start", "start_single"})
+
+ def dismiss_package_level(self):
+ return self._response_schema(next_states={"start", "start_single"})
diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py
index 599b6aabd7..cdbf6efc49 100644
--- a/shopfloor/tests/test_location_content_transfer_single.py
+++ b/shopfloor/tests/test_location_content_transfer_single.py
@@ -456,6 +456,52 @@ def test_stock_out_line_wrong_parameters(self):
response, move_lines.mapped("picking_id"),
)
+ def test_dismiss_package_level_ok(self):
+ """Open a package level"""
+ package_level = self.picking1.move_line_ids.package_level_id
+ move_lines = package_level.move_line_ids
+ response = self.service.dispatch(
+ "dismiss_package_level",
+ params={
+ "location_id": self.content_loc.id,
+ "package_level_id": package_level.id,
+ },
+ )
+ self.assertFalse(package_level.exists())
+ self.assertFalse(move_lines.result_package_id)
+ self.assertFalse(move_lines.package_level_id)
+ self.assertEqual(move_lines.mapped("shopfloor_priority"), [1, 1])
+ move_lines = self.service._find_transfer_move_lines(self.content_loc)
+ self.assert_response_start_single(
+ response,
+ move_lines.mapped("picking_id"),
+ message=self.service.msg_store.package_open(),
+ )
+
+ def test_dismiss_package_level_error_no_package_level(self):
+ """Open a package level, send unknown package level id"""
+ response = self.service.dispatch(
+ "dismiss_package_level",
+ params={"location_id": self.content_loc.id, "package_level_id": 0},
+ )
+ move_lines = self.service._find_transfer_move_lines(self.content_loc)
+ self.assert_response_start_single(
+ response,
+ move_lines.mapped("picking_id"),
+ message=self.service.msg_store.record_not_found(),
+ )
+
+ def test_dismiss_package_level_error_no_location(self):
+ """Open a package level, send unknown location id"""
+ package_level = self.picking1.move_line_ids.package_level_id
+ response = self.service.dispatch(
+ "dismiss_package_level",
+ params={"location_id": 0, "package_level_id": package_level.id},
+ )
+ self.assert_response_start(
+ response, message=self.service.msg_store.record_not_found(),
+ )
+
class LocationContentTransferSingleSpecialCase(LocationContentTransferCommonCase):
"""Tests for endpoint used from state start_single (special cases)
From 739e73673fc62ad04c960bc0a5240750aa0496e5 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Thu, 24 Sep 2020 15:13:50 +0200
Subject: [PATCH 375/940] Fix error when product has no default_code
The default code is not required on products.
When declared as non-nullable in the return schema, it makes the whole
request fail with a 500 error.
---
shopfloor/services/schema.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index d1d424c3a8..4d8742930b 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -109,7 +109,7 @@ def product(self):
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"display_name": {"type": "string", "nullable": False, "required": True},
- "default_code": {"type": "string", "nullable": False, "required": True},
+ "default_code": {"type": "string", "nullable": True, "required": True},
"barcode": {"type": "string", "nullable": True, "required": False},
"supplier_code": {"type": "string", "nullable": True, "required": False},
"packaging": self._schema_list_of(self.packaging()),
From 7ea5feae48b37279f2137fd358d58127a520f328 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 25 Sep 2020 07:56:00 +0200
Subject: [PATCH 376/940] backend: Rename method for better understanding
"check" doesn't really imply a side-effect and is confusing.
"split" describes better what it does (even if it doesn't always split,
the end goal is the same).
---
shopfloor/models/stock_move_line.py | 2 +-
shopfloor/services/checkout.py | 2 +-
shopfloor/services/cluster_picking.py | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index 8028b5cff0..f089481203 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -104,7 +104,7 @@ def _split_pickings_from_source_location(self):
pickings = new_picking
return pickings
- def _check_qty_to_be_done(self, qty_done, split_partial=True, **split_default_vals):
+ def _split_qty_to_be_done(self, qty_done, split_partial=True, **split_default_vals):
"""Check qty to be done for current move line. Split it if needed.
:param qty_done: qty expected to be done
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index eb8f129649..a4753fb0de 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -504,7 +504,7 @@ def _change_line_qty(
else:
new_line = self.env["stock.move.line"]
if qty_done > 0:
- new_line, qty_check = move_line._check_qty_to_be_done(
+ new_line, qty_check = move_line._split_qty_to_be_done(
qty_done, split_partial=True, result_package_id=False,
)
if qty_check == "greater":
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 0041c5af23..660d0dc782 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -566,7 +566,7 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit
batch, message=self.msg_store.operation_not_found()
)
- new_line, qty_check = move_line._check_qty_to_be_done(quantity)
+ new_line, qty_check = move_line._split_qty_to_be_done(quantity)
if qty_check == "greater":
return self._response_for_scan_destination(
move_line,
From 971ca746f319a1f6f11f037de0bad0ca370a49d5 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 25 Sep 2020 08:43:26 +0200
Subject: [PATCH 377/940] Add missing imports of tests
---
shopfloor/tests/__init__.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index ca80513434..d2b61ffdf3 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -18,6 +18,7 @@
from . import test_checkout_select
from . import test_checkout_scan_line
from . import test_checkout_select_line
+from . import test_checkout_select_package_base
from . import test_checkout_set_qty
from . import test_checkout_scan_package_action
from . import test_checkout_new_package
@@ -28,7 +29,10 @@
from . import test_checkout_cancel_line
from . import test_checkout_done
from . import test_delivery_base
+from . import test_delivery_done
from . import test_delivery_scan_deliver
+from . import test_delivery_reset_qty_done_line
+from . import test_delivery_reset_qty_done_pack
from . import test_delivery_set_qty_done_pack
from . import test_delivery_set_qty_done_line
from . import test_delivery_list_stock_picking
@@ -51,4 +55,5 @@
from . import test_zone_picking_unload_all
from . import test_zone_picking_unload_set_destination
from . import test_misc
+from . import test_scan_anything
from . import test_stock_split
From 502a3d825a8fb58a8f56cc311217259bca8d2537 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 25 Sep 2020 10:36:55 +0200
Subject: [PATCH 378/940] backend: fix reset of result_package_id on
move._action_assign()
This is actually an issue in the core stock module, which is fixed
locally as it is much more likely to happen in shopfloor than any
other places (and the consequences if it happens in shopfloor have
no workaround, while you can still edit the move lines when it happens
on the UI).
Reported as https://github.com/odoo/odoo/issues/58491
The details about the issue are there.
In short: calling _action_assign on a stock.move create/write package
levels which may in turn replace the "result_package_id" of a move line,
even if it was already set by a user.
To prevent this, we skip the package level sync when:
* any line related to the package has a result_package_id set
* any line related to the package has a qty_done set
Depending on the response of odoo to this issue, we may later remove
this commit partially or completely.
---
shopfloor/models/stock_picking.py | 19 +++++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_move_action_assign.py | 82 ++++++++++++++++++++++
3 files changed, 102 insertions(+)
create mode 100644 shopfloor/tests/test_move_action_assign.py
diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py
index 5f88110457..f42bed1277 100644
--- a/shopfloor/models/stock_picking.py
+++ b/shopfloor/models/stock_picking.py
@@ -36,3 +36,22 @@ def _calc_weight(self):
for move_line in self.mapped("move_line_ids"):
weight += move_line.product_qty * move_line.product_id.weight
return weight
+
+ def _check_move_lines_map_quant_package(self, package):
+ # see tests/test_move_action_assign.py for details
+ pack_move_lines = self.move_line_ids.filtered(
+ lambda ml: ml.package_id == package
+ )
+ # if we set a qty_done on any line, it's picked, we don't want
+ # to change it in any case, so we ignore the package level
+ if any(pack_move_lines.mapped("qty_done")):
+ return False
+ # if we already changed the destination package, do not create
+ # a new package level
+ if any(
+ line.result_package_id != package
+ for line in pack_move_lines
+ if line.result_package_id
+ ):
+ return False
+ return super()._check_move_lines_map_quant_package(package)
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index d2b61ffdf3..571ed4a34f 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -55,5 +55,6 @@
from . import test_zone_picking_unload_all
from . import test_zone_picking_unload_set_destination
from . import test_misc
+from . import test_move_action_assign
from . import test_scan_anything
from . import test_stock_split
diff --git a/shopfloor/tests/test_move_action_assign.py b/shopfloor/tests/test_move_action_assign.py
new file mode 100644
index 0000000000..7f387f7d6b
--- /dev/null
+++ b/shopfloor/tests/test_move_action_assign.py
@@ -0,0 +1,82 @@
+from .common import CommonCase
+
+
+class TestStockMoveActionAssign(CommonCase):
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.wh = cls.env.ref("stock.warehouse0")
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.wh.sudo().delivery_steps = "pick_pack_ship"
+
+ def test_action_assign_package_level(self):
+ """calling _action_assign on move does not erase lines' "result_package_id"
+
+ At the end of the method ``StockMove._action_assign()``, the method
+ ``StockPicking._check_entire_pack()`` is called. This method compares
+ the move lines with the quants of their source package, and if the entire
+ package is moved at once in the same transfer, a ``stock.package_level`` is
+ created. On creation of a ``stock.package_level``, the result package of
+ the move lines is directly updated with the entire package.
+
+ This is good on the first assign of the move, but when we call assign for
+ the second time on a move, for instance because it was made partially available
+ and we want to assign the remaining, it can override the result package we
+ selected before.
+
+ An override of ``StockPicking._check_move_lines_map_quant_package()`` ensures
+ that we ignore:
+
+ * picked lines (qty_done > 0)
+ * lines with a different result package already
+ """
+ package = self.env["stock.quant.package"].create({"name": "Src Pack"})
+ dest_package1 = self.env["stock.quant.package"].create({"name": "Dest Pack1"})
+
+ picking = self._create_picking(
+ picking_type=self.wh.pick_type_id, lines=[(self.product_a, 50)]
+ )
+ self._update_qty_in_location(
+ picking.location_id, self.product_a, 20, package=package
+ )
+ picking.action_assign()
+
+ self.assertEqual(picking.state, "assigned")
+ self.assertEqual(picking.package_level_ids.package_id, package)
+
+ move = picking.move_lines
+ line = move.move_line_ids
+
+ # we are no longer moving the entire package
+ line.qty_done = 20
+ line.result_package_id = dest_package1
+
+ # create remaining quantity
+ new_package = self.env["stock.quant.package"].create({"name": "New Pack"})
+ self._update_qty_in_location(
+ picking.location_id, self.product_a, 30, package=new_package
+ )
+
+ move._action_assign()
+ new_line = move.move_line_ids - line
+
+ # At the end of _action_assign(), StockPicking._check_entire_pack() is
+ # called, which, by default, look the sum of the move lines qties, and
+ # if they match a package, it:
+ #
+ # * creates a package level
+ # * updates all the move lines result package with the package,
+ # including the 'done' lines
+ #
+ # These checks ensure that we prevent this to happen if we already set
+ # a result package.
+ self.assertRecordValues(
+ line + new_line,
+ [
+ {"qty_done": 20, "result_package_id": dest_package1.id},
+ {"qty_done": 0, "result_package_id": new_package.id},
+ ],
+ )
From 5d010898a9e797cd63c02bf94e7ed9e483838e6d Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 25 Sep 2020 07:59:38 +0200
Subject: [PATCH 379/940] backend: Fix issue on stock out endpoints
When we create a stock issue, create an inventory with the quantity
of units to keep. The quantity to keep is the quantity of products
which have been picked (qty_done > 0). To do so, we unreserve the move
lines in the location where qty_done == 0, so remain only the picked
ones, and when creating the inventory, we sum the move lines quantities.
As the state of a move line is a related to the move, we have to include
"partially_available" ones.
It is important to keep the domain on the state to exclude cancel and
done moves, and it should be more efficient to filter on
assigned/partially_available than to exclude all the others.
A scenario where it was observed is:
In a location, we have 2 packages:
* Pack1: 2 units
* Pack2: 100 units
We create a move for 100 units, it creates 2 move lines:
* 2 units from Pack1
* 98 units from Pack2
In the application, we scan Pack1, move only 1 unit to a destination
Bin. The next line is shown for the remaining unit of Pack1, we use the
"stock out" button.
At this point, what we expect:
* The inventory for Pack1 is created with 1 unit (to satisfy the first
unit put in the destination Bin)
Before the correction:
* The inventory for Pack1 is created with 0 unit, because the move is
"partially_available", so the move line for the picked unit is ignored.
I researched for other occurrences where we do not search for
"partially_available" on move lines and fixed them.
---
shopfloor/actions/inventory.py | 2 +-
shopfloor/services/cluster_picking.py | 4 +-
.../tests/test_cluster_picking_stock_issue.py | 52 +++++++++++++++++++
3 files changed, 55 insertions(+), 3 deletions(-)
diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py
index f195c05d11..c0a32de3fe 100644
--- a/shopfloor/actions/inventory.py
+++ b/shopfloor/actions/inventory.py
@@ -124,7 +124,7 @@ def _stock_issue_get_related_move_lines(self, move, location, package, lot):
("product_id", "=", move.product_id.id),
("package_id", "=", package.id),
("lot_id", "=", lot.id),
- ("state", "=", "assigned"),
+ ("state", "in", ("assigned", "partially_available")),
]
return self.env["stock.move.line"].search(domain)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 660d0dc782..5edecae6f6 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -341,7 +341,7 @@ def _lines_to_pick(self, picking_batch):
return self._lines_for_picking_batch(
picking_batch,
filter_func=lambda l: (
- l.state == "assigned"
+ l.state in ("assigned", "partially_available")
# On 'StockPicking.action_assign()', result_package_id is set to
# the same package as 'package_id'. Here, we need to exclude lines
# that were already put into a bin, i.e. the destination package
@@ -657,7 +657,7 @@ def _data_for_unload_single(self, batch, package):
def _filter_for_unload(self, line):
return (
- line.state == "assigned"
+ line.state in ("assigned", "partially_available")
and line.qty_done > 0
and line.result_package_id
and not line.shopfloor_unloaded
diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py
index 8d1eaa0a83..130319cea7 100644
--- a/shopfloor/tests/test_cluster_picking_stock_issue.py
+++ b/shopfloor/tests/test_cluster_picking_stock_issue.py
@@ -340,3 +340,55 @@ def test_stock_issue_reserve_elsewhere(self):
),
self.shelf2,
)
+
+ def test_stock_issue_similar_move_with_picked_line(self):
+ """Stock issue on the remaining of a line on partial move
+
+ We have a move with 10 units.
+ 2 are reserved in a package. The remaining in another package.
+ We pick 1 of the first package and put it in a bin.
+ A new move line of 1 is created to pick in the first package: we
+ declare a stock out on it.
+ The first move line must be untouched, the second line for the remaining
+ should pick one more item in the other package.
+ """
+ package1 = self.env["stock.quant.package"].create({"name": "PACKAGE_1"})
+ package2 = self.env["stock.quant.package"].create({"name": "PACKAGE_2"})
+ self._update_qty_in_location(self.shelf1, self.product_a, 2, package=package1)
+ self._update_qty_in_location(self.shelf1, self.product_a, 200, package=package2)
+ self.move1._action_assign()
+ self.move2._action_assign()
+ self.move3._action_assign()
+ self._simulate_batch_selected(self.batch, fill_stock=False)
+ self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"})
+
+ pick_line1, pick_line2 = self.move1.move_line_ids
+ new_line, __ = pick_line1._split_qty_to_be_done(1)
+ self._set_dest_package_and_done(pick_line1, self.dest_package)
+
+ self.assertEqual(pick_line1.product_qty, 1.0)
+ self.assertEqual(new_line.product_qty, 1.0)
+ self.assertEqual(pick_line2.product_qty, 8.0)
+ # on the third move, the operator can't pick anymore in shelf1
+ # because there is nothing inside, they declare a stock issue
+ self._stock_issue(new_line, next_line_func=lambda: pick_line2)
+
+ self.assertRecordValues(
+ # check that the first move line of the move was not altered
+ pick_line1,
+ [
+ {
+ "location_id": self.shelf1.id,
+ "qty_done": 1.0,
+ "result_package_id": self.dest_package.id,
+ }
+ ],
+ )
+ # the line on which we declared stock out does not exists
+ self.assertFalse(new_line.exists())
+ # the second line to pick has been raised to 9 instead of 8
+ # initially, to compensate the stock out
+ self.assertEqual(pick_line2.product_qty, 9.0)
+
+ # quant with stock out has been updated
+ self.assertEqual(package1.quant_ids.quantity, 1.0)
From f982a3564d8215445721a3c47fa4060ea1a83817 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Thu, 1 Oct 2020 12:22:27 +0200
Subject: [PATCH 380/940] shopfloor: zone_picking, show if location are about
to be empty (#90)
* shopfloor: zone_picking, show if location are about to be empty
On 'select_line' screen, inform the user that if he process this move line
he will empty the location.
This could mean for him that he'll be able to retrieve a pallet at the
same time without taking one in another place to prepare the goods.
---
shopfloor/models/stock_location.py | 29 +++++++++-----
shopfloor/services/zone_picking.py | 20 +++++++++-
shopfloor/tests/test_zone_picking_base.py | 20 +++++-----
.../tests/test_zone_picking_select_line.py | 39 +++++++++++++++++++
4 files changed, 88 insertions(+), 20 deletions(-)
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
index eb8f6909ac..9641cec1a0 100644
--- a/shopfloor/models/stock_location.py
+++ b/shopfloor/models/stock_location.py
@@ -45,25 +45,36 @@ def _compute_reserved_move_lines(self):
for rec in self:
rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()})
- def planned_qty_in_location_is_empty(self):
+ def planned_qty_in_location_is_empty(self, move_lines=None):
"""Return if a location will be empty when move lines will be confirmed
Used for the "zero check". We need to know if a location is empty, but since
we set the move lines to "done" only at the end of the unload workflow, we
have to look at the qty_done of the move lines from this location.
+
+ With `move_lines` we can force the use of the given move lines for the check.
+ This allows to know that the location will be empty if we process only
+ these move lines.
"""
self.ensure_one()
quants = self.env["stock.quant"].search(
[("quantity", ">", 0), ("location_id", "=", self.id)]
)
remaining = sum(quants.mapped("quantity"))
- lines = self.env["stock.move.line"].search(
- [
- ("state", "!=", "done"),
- ("location_id", "=", self.id),
- ("qty_done", ">", 0),
- ]
- )
- planned = remaining - sum(lines.mapped("qty_done"))
+ move_line_qty_field = "qty_done"
+ if move_lines:
+ move_lines = move_lines.filtered(
+ lambda m: m.state not in ("cancel", "done")
+ )
+ move_line_qty_field = "product_uom_qty"
+ else:
+ move_lines = self.env["stock.move.line"].search(
+ [
+ ("state", "not in", ("cancel", "done")),
+ ("location_id", "=", self.id),
+ ("qty_done", ">", 0),
+ ]
+ )
+ planned = remaining - sum(move_lines.mapped(move_line_qty_field))
compare = float_compare(planned, 0, precision_rounding=0.01)
return compare <= 0
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index d51ae01c0e..d36e5ffd09 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -220,11 +220,17 @@ def _data_for_move_line(self, zone_location, picking_type, move_line):
}
def _data_for_move_lines(self, zone_location, picking_type, move_lines):
- return {
+ data = {
"zone_location": self.data.location(zone_location),
"picking_type": self.data.picking_type(picking_type),
"move_lines": self.data.move_lines(move_lines, with_picking=True),
}
+ for data_move_line in data["move_lines"]:
+ move_line = self.env["stock.move.line"].browse(data_move_line["id"])
+ data_move_line[
+ "empty_location_src"
+ ] = move_line.location_id.planned_qty_in_location_is_empty(move_line)
+ return data
def _data_for_location(self, zone_location, picking_type, location):
return {
@@ -1428,7 +1434,7 @@ def _states(self):
return {
"start": {},
"select_picking_type": self._schema_for_select_picking_type,
- "select_line": self._schema_for_move_lines,
+ "select_line": self._schema_for_move_lines_empty_location,
"set_line_destination": self._schema_for_move_line,
"zero_check": self._schema_for_zero_check,
"change_pack_lot": self._schema_for_move_line,
@@ -1548,6 +1554,16 @@ def _schema_for_move_lines(self):
}
return schema
+ @property
+ def _schema_for_move_lines_empty_location(self):
+ schema = self._schema_for_move_lines
+ schema["move_lines"]["schema"]["schema"]["empty_location_src"] = {
+ "type": "boolean",
+ "nullable": False,
+ "required": True,
+ }
+ return schema
+
@property
def _schema_for_zero_check(self):
schema = {
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 05c019c4bc..f2cb932382 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -216,16 +216,18 @@ def _assert_response_select_line(
message=None,
popup=None,
):
+ data = {
+ "zone_location": self.data.location(zone_location),
+ "picking_type": self.data.picking_type(picking_type),
+ "move_lines": self.data.move_lines(move_lines, with_picking=True),
+ }
+ for data_move_line in data["move_lines"]:
+ move_line = self.env["stock.move.line"].browse(data_move_line["id"])
+ data_move_line[
+ "empty_location_src"
+ ] = move_line.location_id.planned_qty_in_location_is_empty(move_line)
self.assert_response(
- response,
- next_state=state,
- data={
- "zone_location": self.data.location(zone_location),
- "picking_type": self.data.picking_type(picking_type),
- "move_lines": self.data.move_lines(move_lines, with_picking=True),
- },
- message=message,
- popup=popup,
+ response, next_state=state, data=data, message=message, popup=popup,
)
def assert_response_select_line(
diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py
index fc442b851e..ba02eac993 100644
--- a/shopfloor/tests/test_zone_picking_select_line.py
+++ b/shopfloor/tests/test_zone_picking_select_line.py
@@ -396,3 +396,42 @@ def test_prepare_unload_buffer_multi_line_same_destination(self):
self.assert_response_unload_all(
response, zone_location, picking_type, self.picking5.move_line_ids,
)
+
+ def test_list_move_lines_empty_location(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "list_move_lines",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "order": "location",
+ },
+ )
+ move_lines = self.service._find_location_move_lines(
+ zone_location, picking_type, order="location"
+ )
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines,
+ )
+ data_move_lines = response["data"]["select_line"]["move_lines"]
+ # Check that the move line in "Zone sub-location 1" is about to empty
+ # its location if we process it
+ data_move_line = [
+ m
+ for m in data_move_lines
+ if m["location_src"]["barcode"] == "ZONE_SUBLOCATION_1"
+ ][0]
+ self.assertTrue(data_move_line["empty_location_src"])
+ # Same check with the internal method
+ move_line = self.env["stock.move.line"].browse(data_move_line["id"])
+ location_src = move_line.location_id
+ move_line_will_empty_location = location_src.planned_qty_in_location_is_empty(
+ move_lines=move_line
+ )
+ self.assertTrue(move_line_will_empty_location)
+ # But if we check the location without giving the move line as parameter,
+ # knowing that this move line hasn't its 'qty_done' field filled,
+ # the location won't be considered empty with such pending move line
+ move_line_will_empty_location = location_src.planned_qty_in_location_is_empty()
+ self.assertFalse(move_line_will_empty_location)
From 687c23e9e5f0fabea79593cd93b70d43d8037a4b Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 5 Oct 2020 11:04:31 +0200
Subject: [PATCH 381/940] shopfloor: zone_picking rename empty_location_src ->
location_will_be_empty
---
shopfloor/services/zone_picking.py | 9 +++++++--
shopfloor/tests/test_zone_picking_base.py | 2 +-
shopfloor/tests/test_zone_picking_select_line.py | 2 +-
3 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index d36e5ffd09..602e387cfc 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -226,9 +226,14 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines):
"move_lines": self.data.move_lines(move_lines, with_picking=True),
}
for data_move_line in data["move_lines"]:
+ # TODO: this could be expensive, think about a better way
+ # to retrieve if location will be empty.
+ # Maybe group lines by location and compute only once.
move_line = self.env["stock.move.line"].browse(data_move_line["id"])
+ # `location_will_be_empty` flag states if, by processing this move line
+ # and picking the product, the location will be emptied.
data_move_line[
- "empty_location_src"
+ "location_will_be_empty"
] = move_line.location_id.planned_qty_in_location_is_empty(move_line)
return data
@@ -1557,7 +1562,7 @@ def _schema_for_move_lines(self):
@property
def _schema_for_move_lines_empty_location(self):
schema = self._schema_for_move_lines
- schema["move_lines"]["schema"]["schema"]["empty_location_src"] = {
+ schema["move_lines"]["schema"]["schema"]["location_will_be_empty"] = {
"type": "boolean",
"nullable": False,
"required": True,
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index f2cb932382..948ec31161 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -224,7 +224,7 @@ def _assert_response_select_line(
for data_move_line in data["move_lines"]:
move_line = self.env["stock.move.line"].browse(data_move_line["id"])
data_move_line[
- "empty_location_src"
+ "location_will_be_empty"
] = move_line.location_id.planned_qty_in_location_is_empty(move_line)
self.assert_response(
response, next_state=state, data=data, message=message, popup=popup,
diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py
index ba02eac993..614cd6a3ff 100644
--- a/shopfloor/tests/test_zone_picking_select_line.py
+++ b/shopfloor/tests/test_zone_picking_select_line.py
@@ -422,7 +422,7 @@ def test_list_move_lines_empty_location(self):
for m in data_move_lines
if m["location_src"]["barcode"] == "ZONE_SUBLOCATION_1"
][0]
- self.assertTrue(data_move_line["empty_location_src"])
+ self.assertTrue(data_move_line["location_will_be_empty"])
# Same check with the internal method
move_line = self.env["stock.move.line"].browse(data_move_line["id"])
location_src = move_line.location_id
From 3842b4fc830553341a0ba70170097d1ada2ad049 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 7 Oct 2020 08:50:13 +0200
Subject: [PATCH 382/940] backend: add option on menu to allow unreserving
moves
---
shopfloor/models/shopfloor_menu.py | 38 ++++++++++++++++++++++++++++++
shopfloor/views/shopfloor_menu.xml | 15 ++++++++++++
2 files changed, 53 insertions(+)
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index 23b7f0bd9e..0a16a91ab5 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -11,6 +11,11 @@ class ShopfloorMenu(models.Model):
"location_content_transfer",
)
+ _scenario_allowing_unreserve_other_moves = (
+ "single_pack_transfer",
+ "location_content_transfer",
+ )
+
name = fields.Char(translate=True)
sequence = fields.Integer()
profile_ids = fields.Many2many(
@@ -32,6 +37,15 @@ class ShopfloorMenu(models.Model):
" scanned and no move already exists. Any new move is created in the"
" selected operation type, so it can be active only when one type is selected.",
)
+ unreserve_other_moves_is_possible = fields.Boolean(
+ compute="_compute_unreserve_other_moves_is_possible"
+ )
+ allow_unreserve_other_moves = fields.Boolean(
+ string="Allow to process reserved quantities",
+ default=False,
+ help="If you tick this box, this scenario will allow operator to move"
+ " goods even if a reservation is made by a different operation type.",
+ )
active = fields.Boolean(default=True)
def _selection_scenario(self):
@@ -65,6 +79,30 @@ def _check_allow_move_create(self):
_("Creation of moves is not allowed for menu {}.").format(menu.name)
)
+ @api.depends("scenario", "picking_type_ids")
+ def _compute_unreserve_other_moves_is_possible(self):
+ for menu in self:
+ menu.unreserve_other_moves_is_possible = (
+ menu.scenario in self._scenario_allowing_unreserve_other_moves
+ )
+
+ @api.onchange("unreserve_other_moves_is_possible")
+ def onchange_unreserve_other_moves_is_possible(self):
+ self.allow_unreserve_other_moves = self.unreserve_other_moves_is_possible
+
+ @api.constrains("scenario", "picking_type_ids", "allow_unreserve_other_moves")
+ def _check_allow_unreserve_other_moves(self):
+ for menu in self:
+ if (
+ menu.allow_unreserve_other_moves
+ and not menu.unreserve_other_moves_is_possible
+ ):
+ raise exceptions.ValidationError(
+ _(
+ "Processing reserved quantities is" " not allowed for menu {}."
+ ).format(menu.name)
+ )
+
# ATM the goal is to block using single_pack_transfer (SPT)
# w/out moving the full pkg.
# Is not optimal, but is mandatory as long as SPT does not work w/ moves
diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml
index 149d4cea7b..7777787659 100644
--- a/shopfloor/views/shopfloor_menu.xml
+++ b/shopfloor/views/shopfloor_menu.xml
@@ -23,6 +23,11 @@
name="allow_move_create"
attrs="{'invisible': [('move_create_is_possible', '=', False)]}"
/>
+
+
@@ -64,6 +69,16 @@
+
+
+
+
From 6436c9497587f8eb48f6490fe54d437b66147c1d Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 7 Oct 2020 11:17:38 +0200
Subject: [PATCH 383/940] location transfer: implement unreserve of existing
moves
When we scan a location which already have reserved move lines for other
picking types, and the option is active on the menu, unreserve the moves,
create new moves for the location content transfer and re-assign the
unreserved moves.
If one of the move that would be unreserved has already been picked
(qty_done > 0), an error is returned.
I added a savepoint to rollback when the we had to unreserve moves and
finally we can't reserve the location transfer moves for any reason. As
a side effect, we don't need anymore to cancel them as they will be
rollbacked.
---
shopfloor/actions/message.py | 8 ++
shopfloor/models/stock_package_level.py | 26 +++++
.../services/location_content_transfer.py | 108 +++++++++++++++---
.../test_location_content_transfer_start.py | 89 ++++++++++++++-
4 files changed, 216 insertions(+), 15 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 00724daf90..e084794e86 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -355,6 +355,14 @@ def location_content_transfer_complete(self, location_src, location_dest):
),
}
+ def picking_already_started_in_location(self, pickings):
+ return {
+ "message_type": "error",
+ "body": _(
+ "Picking has already been started in this location in transfer(s): {}"
+ ).format(", ".join(pickings.mapped("name"))),
+ }
+
def transfer_done_success(self, picking):
return {
"message_type": "success",
diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py
index 24b1b8a531..a8569cdc3d 100644
--- a/shopfloor/models/stock_package_level.py
+++ b/shopfloor/models/stock_package_level.py
@@ -27,3 +27,29 @@ def replace_package(self, new_package):
line.owner_id = quant.owner_id
self.package_id = new_package
+
+ def shallow_unlink(self):
+ """Unlink but keep the moves
+ A package level has a relation to "move_ids" only when the
+ package level was created first from the UI and it created
+ its move.
+ When we unlink a package level, it deletes the move it created.
+ But in some cases, we want to keep the move, e.g.:
+ * create a package level from the UI to move a package
+ * it generates a move for the matching product quantity
+ * we use a barcode scenario such as cluster or zone picking
+ * we use the "replace package" button
+ * when replacing the package, we have to delete the package level,
+ but we still have the same need in term of "I want X products",
+ so we have to keep the move
+ * another case is when we "dismiss" the package level in the location
+ content transfer scenario, we want to keep the "need" in moves, but
+ we are no longer moving the entire package level
+ """
+ self.move_ids.package_level_id = False
+ self.unlink()
+
+ def explode_package(self):
+ move_lines = self.move_line_ids
+ move_lines.result_package_id = False
+ self.shallow_unlink()
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 3e3e34cd9c..abc5dfeee2 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -1,4 +1,9 @@
+import uuid
+
+from psycopg2 import sql
+
from odoo import _
+from odoo.sql_db import clear_env, flush_env
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -183,6 +188,12 @@ def _find_location_move_lines_domain(self, location):
("shopfloor_user_id", "=", False),
]
+ def _find_location_all_move_lines_domain(self, location):
+ return [
+ ("location_id", "=", location.id),
+ ("state", "in", ("assigned", "partially_available")),
+ ]
+
def _find_location_move_lines(self, location):
"""Find lines that potentially are to move in the location"""
return self.env["stock.move.line"].search(
@@ -213,6 +224,42 @@ def _create_moves_from_location(self, location):
)
return self.env["stock.move"].create(move_vals_list)
+ def _unreserve_other_lines(self, location, move_lines):
+ """Unreserve move lines in location in another picking type
+
+ Returns a tuple of (
+ move lines that stays in the location to process,
+ moves to reserve again,
+ response to return to client in case of error
+ )
+ """
+ lines_other_picking_types = move_lines.filtered(
+ lambda line: line.picking_id.picking_type_id not in self.picking_types
+ )
+ if not lines_other_picking_types:
+ return move_lines
+ unreserved_moves = move_lines.move_id
+ location_move_lines = self.env["stock.move.line"].search(
+ self._find_location_all_move_lines_domain(location)
+ )
+ extra_move_lines = location_move_lines - move_lines
+ if extra_move_lines:
+ return (
+ self.env["stock.move.line"].browse(),
+ self.env["stock.move"].browse(),
+ self._response_for_start(
+ message=self.msg_store.picking_already_started_in_location(
+ extra_move_lines.picking_id
+ )
+ ),
+ )
+ package_levels = move_lines.package_level_id
+ # if we leave the package level around, it will try to reserve
+ # the same package as before
+ package_levels.explode_package()
+ unreserved_moves._do_unreserve()
+ return (move_lines - lines_other_picking_types, unreserved_moves, None)
+
def scan_location(self, barcode):
"""Scan start location
@@ -247,20 +294,37 @@ def scan_location(self, barcode):
pickings = move_lines.picking_id
picking_types = pickings.mapped("picking_type_id")
- if len(picking_types) > 1:
- return self._response_for_start(
- message={
- "message_type": "error",
- "body": _("This location content can't be moved at once."),
- }
- )
- if picking_types - self.picking_types:
- return self._response_for_start(
- message={
- "message_type": "error",
- "body": _("This location content can't be moved using this menu."),
- }
+ savepoint_name = uuid.uuid1().hex
+ flush_env(self.env.cr, clear=False)
+ # pylint: disable=sql-injection
+ self.env.cr.execute(
+ sql.SQL("SAVEPOINT {}").format(sql.Identifier(savepoint_name))
+ )
+
+ unreserved_moves = self.env["stock.move"].browse()
+ if self.work.menu.allow_unreserve_other_moves:
+ move_lines, unreserved_moves, response = self._unreserve_other_lines(
+ location, move_lines
)
+ if response:
+ return response
+ else:
+ if len(picking_types) > 1:
+ return self._response_for_start(
+ message={
+ "message_type": "error",
+ "body": _("This location content can't be moved at once."),
+ }
+ )
+ if picking_types - self.picking_types:
+ return self._response_for_start(
+ message={
+ "message_type": "error",
+ "body": _(
+ "This location content can't be moved using this menu."
+ ),
+ }
+ )
# Ensure we process move lines related to pickings having only one source
# location among all their move lines. If there are different source
# locations, we put the move lines we are interested in in a separate picking.
@@ -291,7 +355,15 @@ def scan_location(self, barcode):
new_moves._action_confirm(merge=False)
new_moves._action_assign()
if not all([x.state == "assigned" for x in new_moves]):
- new_moves._action_cancel()
+ clear_env(
+ self.env.cr
+ ) # required to refresh cache data previous savepoint
+ # pylint: disable=sql-injection
+ self.env.cr.execute(
+ sql.SQL("ROLLBACK TO SAVEPOINT {}").format(
+ sql.Identifier(savepoint_name)
+ )
+ )
return self._response_for_start(
message=self.msg_store.new_move_lines_not_assigned()
)
@@ -309,6 +381,14 @@ def scan_location(self, barcode):
pickings.user_id = self.env.uid
+ unreserved_moves._action_assign()
+
+ flush_env(self.env.cr, clear=False)
+ # pylint: disable=sql-injection
+ self.env.cr.execute(
+ sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name))
+ )
+
return self._router_single_or_all_destination(pickings)
def _find_transfer_move_lines_domain(self, location):
diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py
index 66786e1023..62c4a6f694 100644
--- a/shopfloor/tests/test_location_content_transfer_start.py
+++ b/shopfloor/tests/test_location_content_transfer_start.py
@@ -152,7 +152,7 @@ class LocationContentTransferStartSpecialCase(LocationContentTransferCommonCase)
* /scan_location
"""
- def test_scan_location_wrong_picking_type(self):
+ def test_scan_location_wrong_picking_type_error(self):
"""Content has different picking type than menu"""
picking = self._create_picking(
picking_type=self.wh.pick_type_id,
@@ -173,6 +173,93 @@ def test_scan_location_wrong_picking_type(self):
},
)
+ def test_scan_location_wrong_picking_type_allow_unreserve_ok(self):
+ """Content has different picking type than menu, option to unreserve
+
+ The content must be unreserved, new moves created and the previous
+ content re-reserved.
+ """
+ self.menu.sudo().allow_unreserve_other_moves = True
+
+ picking = self._create_picking(
+ picking_type=self.wh.pick_type_id,
+ lines=[(self.product_a, 10), (self.product_b, 10)],
+ )
+ self._fill_stock_for_moves(
+ picking.move_lines, in_package=True, location=self.content_loc
+ )
+ picking.action_assign()
+ # place goods in shelf1 to ensure the original picking can take goods here
+ other_pack_a = self.env["stock.quant.package"].create({})
+ other_pack_b = self.env["stock.quant.package"].create({})
+ self._update_qty_in_location(
+ self.shelf1, self.product_a, 10, package=other_pack_a
+ )
+ self._update_qty_in_location(
+ self.shelf1, self.product_b, 10, package=other_pack_b
+ )
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.content_loc.barcode}
+ )
+ new_picking = self.env["stock.picking"].search(
+ [("picking_type_id", "=", self.picking_type.id)]
+ )
+ self.assertEqual(len(new_picking), 1)
+ self.assert_response_scan_destination_all(response, new_picking)
+ self.assertRecordValues(new_picking, [{"user_id": self.env.uid}])
+ self.assertRecordValues(
+ new_picking.move_line_ids, [{"qty_done": 10.0}, {"qty_done": 10.0}],
+ )
+ self.assertRecordValues(new_picking.package_level_ids, [{"is_done": True}])
+
+ # the original picking must be reserved again, should have taken the goods
+ # of shelf1
+ self.assertRecordValues(
+ picking.move_line_ids,
+ [
+ {
+ "qty_done": 0.0,
+ "location_id": self.shelf1.id,
+ "package_id": other_pack_a.id,
+ },
+ {
+ "qty_done": 0.0,
+ "location_id": self.shelf1.id,
+ "package_id": other_pack_b.id,
+ },
+ ],
+ )
+
+ def test_scan_location_wrong_picking_type_allow_unreserve_error(self):
+ """Content has different picking type than menu, option to unreserve
+
+ If quantity has been partially picked on the existing transfer, prevent
+ to unreserve them.
+ """
+ self.menu.sudo().allow_unreserve_other_moves = True
+
+ picking = self._create_picking(
+ picking_type=self.wh.pick_type_id,
+ lines=[(self.product_a, 10), (self.product_b, 10)],
+ )
+ self._fill_stock_for_moves(
+ picking.move_lines, in_package=True, location=self.content_loc
+ )
+ picking.action_assign()
+ # a user picked qty
+ 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_lines, [{"state": "assigned"}, {"state": "assigned"}]
+ )
+
def test_scan_location_create_moves(self):
"""The scanned location has no move lines but has some quants to move."""
picking_type = self.menu.picking_type_ids
From 32c2f6659205beac57cc877c3edad1146e551ab7 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 7 Oct 2020 14:10:23 +0200
Subject: [PATCH 384/940] single pack transfer: implement unreserve of existing
moves
When we scan a package which already have reserved move lines for other
picking types, and the option is active on the menu, unreserve the moves,
create new moves for the location content transfer and re-assign the
unreserved moves.
If one of the move that would be unreserved has already been picked
(qty_done > 0), an error is returned.
---
shopfloor/services/single_pack_transfer.py | 57 +++++
shopfloor/tests/test_single_pack_transfer.py | 214 ++++++++++++++++--
.../tests/test_single_pack_transfer_base.py | 26 +++
3 files changed, 273 insertions(+), 24 deletions(-)
create mode 100644 shopfloor/tests/test_single_pack_transfer_base.py
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 46f5526225..5f67f6c6d4 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -1,3 +1,10 @@
+import uuid
+
+from psycopg2 import sql
+
+from odoo import fields
+from odoo.sql_db import clear_env, flush_env
+
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -83,6 +90,39 @@ def start(self, barcode, confirmation=False):
("picking_id.picking_type_id", "in", picking_types.ids),
]
)
+
+ # Start a savepoint because we are may unreserve moves of other
+ # picking types. If we do and we can't create a package level after,
+ # we rollback to the initial state
+ savepoint_name = uuid.uuid1().hex
+ flush_env(self.env.cr, clear=False)
+ # pylint: disable=sql-injection
+ self.env.cr.execute(
+ sql.SQL("SAVEPOINT {}").format(sql.Identifier(savepoint_name))
+ )
+ unreserved_moves = self.env["stock.move"].browse()
+ if not package_level:
+ other_move_lines = self.env["stock.move.line"].search(
+ [
+ ("package_id", "=", package.id),
+ # to exclude canceled and done
+ ("state", "in", ("assigned", "partially_available")),
+ ]
+ )
+ if any(line.qty_done > 0 for line in other_move_lines) or (
+ other_move_lines and not self.work.menu.allow_unreserve_other_moves
+ ):
+ picking = fields.first(other_move_lines).picking_id
+ return self._response_for_start(
+ message=self.msg_store.package_already_picked_by(package, picking)
+ )
+ elif other_move_lines and self.work.menu.allow_unreserve_other_moves:
+
+ unreserved_moves = other_move_lines.move_id
+ other_package_levels = other_move_lines.package_level_id
+ other_package_levels.explode_package()
+ unreserved_moves._do_unreserve()
+
# State is computed, can't use it in the domain. And it's probably faster
# to filter here rather than using a domain on "picking_id.state" that would
# use a sub-search on stock.picking: we shouldn't have dozens of package levels
@@ -95,6 +135,14 @@ def start(self, barcode, confirmation=False):
package_level = self._create_package_level(package)
if not package_level:
+ # restore any unreserved move/package level
+ clear_env(self.env.cr) # required to refresh cache data previous savepoint
+ # pylint: disable=sql-injection
+ self.env.cr.execute(
+ sql.SQL("ROLLBACK TO SAVEPOINT {}").format(
+ sql.Identifier(savepoint_name)
+ )
+ )
return self._response_for_start(
message=self.msg_store.no_pending_operation_for_pack(package)
)
@@ -104,6 +152,15 @@ def start(self, barcode, confirmation=False):
)
if not package_level.is_done:
package_level.is_done = True
+
+ unreserved_moves._action_assign()
+
+ flush_env(self.env.cr, clear=False)
+ # pylint: disable=sql-injection
+ self.env.cr.execute(
+ sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name))
+ )
+
return self._response_for_scan_location(package_level)
def _create_package_level(self, package):
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 041aa51b56..9057f51755 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -1,22 +1,12 @@
from odoo.tests.common import Form
-from .common import CommonCase
+from .test_single_pack_transfer_base import SinglePackTransferCommonBase
-class SinglePackTransferCase(CommonCase):
- @classmethod
- def setUpClassVars(cls, *args, **kwargs):
- super().setUpClassVars(*args, **kwargs)
- cls.menu = cls.env.ref("shopfloor.shopfloor_menu_single_pallet_transfer")
- cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
- cls.wh = cls.profile.warehouse_id
- cls.picking_type = cls.menu.picking_type_ids
-
+class SinglePackTransferCase(SinglePackTransferCommonBase):
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
- # we activate the move creation in tests when needed
- cls.menu.sudo().allow_move_create = False
cls.pack_a = cls.env["stock.quant.package"].create(
{"location_id": cls.stock_location.id}
)
@@ -34,15 +24,6 @@ def setUpClassBaseData(cls, *args, **kwargs):
)
cls.picking = cls._create_initial_move()
- # disable the completion on the picking type, we'll have specific test(s)
- # to check the behavior of this screen
- cls.picking_type.sudo().display_completion_info = False
-
- def setUp(self):
- super().setUp()
- with self.work_on_services(menu=self.menu, profile=self.profile) as work:
- self.service = work.component(usage="single_pack_transfer")
-
@classmethod
def _create_initial_move(cls):
"""Create the move to satisfy the pre-condition before /start"""
@@ -188,9 +169,7 @@ def test_start_no_operation_create(self):
"confirmation_required": False,
}
- self.assert_response(
- response, next_state="scan_location", data=expected_data,
- )
+ self.assert_response(response, next_state="scan_location", data=expected_data)
def test_start_barcode_not_known(self):
"""Test /start when the barcode is unknown
@@ -866,3 +845,190 @@ def test_cancel_not_found(self):
"body": "This operation does not exist anymore.",
},
)
+
+
+class SinglePackTransferSpecialCase(SinglePackTransferCommonBase):
+ def test_start_package_unreserve_ok(self):
+ """Test /start when the package was already reserved...
+
+ ...for another picking type and unreserving is allowed.
+
+ When we scan a location which contains only one package,
+ we want to move this package.
+
+ The pre-conditions:
+
+ * A package exists in Stock/Shelf1.
+ * A stock picking exists to move the package from Stock/Shelf1 to
+ Out with a different picking type. The move is "assigned".
+ * Another package exists in Stock
+
+ Expected result:
+
+ * the original transfer is reserved to move the other package from Stock
+ * a new transfer is created to move the package from Shelf1
+
+ The next step in the workflow is to call /validate with the
+ package level that will set the move and picking to done.
+ """
+ self.menu.sudo().allow_move_create = True
+ self.menu.sudo().allow_unreserve_other_moves = True
+
+ package = self.env["stock.quant.package"].create({})
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package)
+ # create a picking of another picking type
+ picking = self._create_picking(
+ picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)]
+ )
+ picking.action_assign()
+
+ # create another package that should be used when the picking will
+ # get re-assigned
+ package2 = self.env["stock.quant.package"].create({})
+ self._update_qty_in_location(
+ self.stock_location, self.product_a, 10, package=package2
+ )
+
+ barcode = self.shelf1.barcode
+ params = {"barcode": barcode}
+ response = self.service.dispatch("start", params=params)
+
+ new_picking = self.env["stock.picking"].search(
+ [("picking_type_id", "=", self.picking_type.id)]
+ )
+ self.assertEqual(len(new_picking), 1)
+ new_package_level = new_picking.package_level_ids
+
+ self.assert_response(
+ # We only care about the fact that we jump to the next
+ # screen, so it found the pack. The details are already
+ # checked in the test_start test.
+ response,
+ next_state="scan_location",
+ data=dict(
+ self.service._data_after_package_scanned(new_package_level),
+ confirmation_required=False,
+ ),
+ )
+ self.assertRecordValues(
+ picking.package_level_ids, [{"package_id": package2.id}]
+ )
+
+ self.assertRecordValues(new_package_level, [{"package_id": package.id}])
+
+ def test_start_package_unreserve_picked_error(self):
+ """Test /start when the package was already reserved...
+
+ ...for another picking type and the other move is already picked.
+
+ When we scan a location which contains only one package,
+ we want to move this package.
+
+ The pre-conditions:
+
+ * A package exists in Stock/Shelf1.
+ * A stock picking exists to move the package from Stock/Shelf1 to
+ Out with a different picking type. The move is "assigned".
+
+ Expected result:
+
+ * receive an error that we cannot pick the package
+ """
+ self.menu.sudo().allow_move_create = True
+ self.menu.sudo().allow_unreserve_other_moves = True
+
+ package = self.env["stock.quant.package"].create({})
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package)
+ # create a picking of another picking type
+ picking = self._create_picking(
+ picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)]
+ )
+ picking.action_assign()
+
+ # pick the goods
+ picking.move_line_ids.qty_done = 10
+
+ barcode = self.shelf1.barcode
+ params = {"barcode": barcode}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ response,
+ next_state="start",
+ message=self.service.msg_store.package_already_picked_by(package, picking),
+ )
+ # no change in the picking
+ self.assertEqual(picking.state, "assigned")
+ self.assertRecordValues(picking.package_level_ids, [{"package_id": package.id}])
+
+ def test_start_package_unreserve_disabled_error(self):
+ """Test /start when the package was already reserved...
+
+ ...for another picking type and unreserving is disallowed.
+
+ When we scan a location which contains only one package,
+ we want to move this package.
+
+ The pre-conditions:
+
+ * A package exists in Stock/Shelf1.
+ * A stock picking exists to move the package from Stock/Shelf1 to
+ Out with a different picking type. The move is "assigned".
+
+ Expected result:
+
+ * receive an error that we cannot pick the package
+ """
+ self.menu.sudo().allow_move_create = True
+ self.menu.sudo().allow_unreserve_other_moves = False
+
+ package = self.env["stock.quant.package"].create({})
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package)
+ # create a picking of another picking type
+ picking = self._create_picking(
+ picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)]
+ )
+ picking.action_assign()
+ barcode = self.shelf1.barcode
+ params = {"barcode": barcode}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ response,
+ next_state="start",
+ message=self.service.msg_store.package_already_picked_by(package, picking),
+ )
+ # no change in the picking
+ self.assertEqual(picking.state, "assigned")
+ self.assertRecordValues(picking.package_level_ids, [{"package_id": package.id}])
+
+ def test_start_package_unreserve_no_create_error(self):
+ """Test /start when the package was already reserved...
+
+ ...for another picking type and unreserving is allowed
+ and the option to create a move is not allowed.
+
+ This test ensure that the unreservation of the first package
+ is rollbacked.
+
+ """
+ self.menu.sudo().allow_move_create = False
+ self.menu.sudo().allow_unreserve_other_moves = True
+
+ package = self.env["stock.quant.package"].create({})
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package)
+ # create a picking of another picking type
+ picking = self._create_picking(
+ picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)]
+ )
+ picking.action_assign()
+ self.assertEqual(picking.state, "assigned")
+ barcode = self.shelf1.barcode
+ params = {"barcode": barcode}
+ response = self.service.dispatch("start", params=params)
+ self.assert_response(
+ response,
+ next_state="start",
+ message=self.service.msg_store.no_pending_operation_for_pack(package),
+ )
+ # no change in the picking
+ self.assertEqual(picking.state, "assigned")
+ self.assertRecordValues(picking.package_level_ids, [{"package_id": package.id}])
diff --git a/shopfloor/tests/test_single_pack_transfer_base.py b/shopfloor/tests/test_single_pack_transfer_base.py
new file mode 100644
index 0000000000..8f04301fc9
--- /dev/null
+++ b/shopfloor/tests/test_single_pack_transfer_base.py
@@ -0,0 +1,26 @@
+from .common import CommonCase
+
+
+class SinglePackTransferCommonBase(CommonCase):
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_single_pallet_transfer")
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.picking_type = cls.menu.picking_type_ids
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ # we activate the move creation in tests when needed
+ cls.menu.sudo().allow_move_create = False
+
+ # disable the completion on the picking type, we'll have specific test(s)
+ # to check the behavior of this screen
+ cls.picking_type.sudo().display_completion_info = False
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(menu=self.menu, profile=self.profile) as work:
+ self.service = work.component(usage="single_pack_transfer")
From ed16c5f22910dbb4c6a620ded6e54172ba8649f0 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 7 Oct 2020 14:39:37 +0200
Subject: [PATCH 385/940] backend: Add a component to factorize savepoint
methods
---
shopfloor/actions/__init__.py | 1 +
shopfloor/actions/savepoint.py | 44 +++++++++++++++++++
.../services/location_content_transfer.py | 28 ++----------
shopfloor/services/single_pack_transfer.py | 26 ++---------
4 files changed, 51 insertions(+), 48 deletions(-)
create mode 100644 shopfloor/actions/savepoint.py
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index 7aa11fa809..464f279228 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -25,3 +25,4 @@
from . import message
from . import search
from . import inventory
+from . import savepoint
diff --git a/shopfloor/actions/savepoint.py b/shopfloor/actions/savepoint.py
new file mode 100644
index 0000000000..9e031de542
--- /dev/null
+++ b/shopfloor/actions/savepoint.py
@@ -0,0 +1,44 @@
+import uuid
+
+from psycopg2 import sql
+
+from odoo.sql_db import clear_env, flush_env
+
+from odoo.addons.component.core import Component
+
+
+class SavepointBuilder(Component):
+ """Return a new Savepoint instance"""
+
+ _name = "shopfloor.savepoint.action"
+ _inherit = "shopfloor.process.action"
+ _usage = "savepoint"
+
+ def new(self):
+ return Savepoint(self.env.cr)
+
+
+class Savepoint(object):
+ """Wrapper for SQL Savepoint
+
+ Close to "cr.savepoint()" context manager but this class gives more control
+ over when the release/rollback are called.
+ """
+
+ def __init__(self, cr):
+ self._cr = cr
+ self.name = uuid.uuid1().hex
+ flush_env(self._cr, clear=False)
+ self._execute("SAVEPOINT {}")
+
+ def rollback(self):
+ clear_env(self._cr)
+ self._execute("ROLLBACK TO SAVEPOINT {}")
+
+ def release(self):
+ flush_env(self._cr, clear=False)
+ self._execute("RELEASE SAVEPOINT {}")
+
+ def _execute(self, query):
+ # pylint: disable=sql-injection
+ self._cr.execute(sql.SQL(query).format(sql.Identifier(self.name)))
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index abc5dfeee2..88e0762ea3 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -1,9 +1,4 @@
-import uuid
-
-from psycopg2 import sql
-
from odoo import _
-from odoo.sql_db import clear_env, flush_env
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -294,12 +289,7 @@ def scan_location(self, barcode):
pickings = move_lines.picking_id
picking_types = pickings.mapped("picking_type_id")
- savepoint_name = uuid.uuid1().hex
- flush_env(self.env.cr, clear=False)
- # pylint: disable=sql-injection
- self.env.cr.execute(
- sql.SQL("SAVEPOINT {}").format(sql.Identifier(savepoint_name))
- )
+ savepoint = self.actions_for("savepoint").new()
unreserved_moves = self.env["stock.move"].browse()
if self.work.menu.allow_unreserve_other_moves:
@@ -355,15 +345,7 @@ def scan_location(self, barcode):
new_moves._action_confirm(merge=False)
new_moves._action_assign()
if not all([x.state == "assigned" for x in new_moves]):
- clear_env(
- self.env.cr
- ) # required to refresh cache data previous savepoint
- # pylint: disable=sql-injection
- self.env.cr.execute(
- sql.SQL("ROLLBACK TO SAVEPOINT {}").format(
- sql.Identifier(savepoint_name)
- )
- )
+ savepoint.rollback()
return self._response_for_start(
message=self.msg_store.new_move_lines_not_assigned()
)
@@ -383,11 +365,7 @@ def scan_location(self, barcode):
unreserved_moves._action_assign()
- flush_env(self.env.cr, clear=False)
- # pylint: disable=sql-injection
- self.env.cr.execute(
- sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name))
- )
+ savepoint.release()
return self._router_single_or_all_destination(pickings)
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 5f67f6c6d4..0af66ebb2d 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -1,9 +1,4 @@
-import uuid
-
-from psycopg2 import sql
-
from odoo import fields
-from odoo.sql_db import clear_env, flush_env
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
@@ -94,12 +89,7 @@ def start(self, barcode, confirmation=False):
# Start a savepoint because we are may unreserve moves of other
# picking types. If we do and we can't create a package level after,
# we rollback to the initial state
- savepoint_name = uuid.uuid1().hex
- flush_env(self.env.cr, clear=False)
- # pylint: disable=sql-injection
- self.env.cr.execute(
- sql.SQL("SAVEPOINT {}").format(sql.Identifier(savepoint_name))
- )
+ savepoint = self.actions_for("savepoint").new()
unreserved_moves = self.env["stock.move"].browse()
if not package_level:
other_move_lines = self.env["stock.move.line"].search(
@@ -136,13 +126,7 @@ def start(self, barcode, confirmation=False):
if not package_level:
# restore any unreserved move/package level
- clear_env(self.env.cr) # required to refresh cache data previous savepoint
- # pylint: disable=sql-injection
- self.env.cr.execute(
- sql.SQL("ROLLBACK TO SAVEPOINT {}").format(
- sql.Identifier(savepoint_name)
- )
- )
+ savepoint.rollback()
return self._response_for_start(
message=self.msg_store.no_pending_operation_for_pack(package)
)
@@ -155,11 +139,7 @@ def start(self, barcode, confirmation=False):
unreserved_moves._action_assign()
- flush_env(self.env.cr, clear=False)
- # pylint: disable=sql-injection
- self.env.cr.execute(
- sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name))
- )
+ savepoint.release()
return self._response_for_scan_location(package_level)
From f248c84612ac2705bee33496ca56212c66843aef Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 7 Sep 2020 11:04:28 +0200
Subject: [PATCH 386/940] Rework change of package/lot
The previous implementation suffered from several bugs or limitations,
mainly due to how the package levels were handled.
The new behavior when a user asks to change a package is:
* search for other lines that were moving the package, if the lines are
not picked (qty_done > 0), unreserve them
* if the scanned package was not expected in the current location, make
an inventory adjustment to fix it (as before)
* explode [0] the package level of the unreserved lines, otherwise, the next
assign on them would try to reserve the same package again
* explode [0] the package level of the move line where we replace the
package
* update the move line with the package, and update the move line with
the same lot as the quant in the package if any
* if the available quantity in the quant of the package is lesser than
the original quantity, update the move line quantity and run again
"_action_assign" on the move that will generate a new move line for the
remaining
* the call on _action_assign will also regenerate a package level for
the move line (which will set the "result_package_id" to the same value)
if the new package is entirely moved
* try to reserve again the lines previously unreserved
[0] delete the package level and set "result_package_id" to False on
related move lines
The same way, changing a lot unreserve other lines using the same lot
if the whole quantity is reserved. If the quantity is partially
reserved, it only reduces the current quantity to the available (so
the user may do proceeds in 2 steps: take the available part, scan again
the lot which will unreserve the other lines).
When a lot is scanned but there is no known quant in the location, it
means there is an error in the inventory: add the quantity in odoo so
the user is not blocked, and create a control inventory to check.
Tests are moved to test case working only on the "change.package.lot"
component, the tests to change a package or lot in the services are kept
only for simple cases so we tests the responses for success or errors.
---
shopfloor/actions/change_package_lot.py | 235 ++--
shopfloor/actions/inventory.py | 45 +-
shopfloor/actions/message.py | 18 +-
shopfloor/models/stock_move_line.py | 108 +-
shopfloor/models/stock_package_level.py | 25 +-
shopfloor/models/stock_quant_package.py | 33 +
shopfloor/services/cluster_picking.py | 6 +-
.../services/location_content_transfer.py | 3 +-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/common.py | 28 +
.../tests/test_actions_change_package_lot.py | 1167 +++++++++++++++++
.../test_cluster_picking_change_pack_lot.py | 434 +-----
.../test_zone_picking_change_pack_lot.py | 68 +-
13 files changed, 1524 insertions(+), 647 deletions(-)
create mode 100644 shopfloor/tests/test_actions_change_package_lot.py
diff --git a/shopfloor/actions/change_package_lot.py b/shopfloor/actions/change_package_lot.py
index 5d96a56ea2..2537cea5b5 100644
--- a/shopfloor/actions/change_package_lot.py
+++ b/shopfloor/actions/change_package_lot.py
@@ -1,4 +1,5 @@
-from odoo import _
+from odoo import _, exceptions
+from odoo.tools.float_utils import float_compare, float_is_zero
from odoo.addons.component.core import Component
@@ -24,28 +25,30 @@ def change_lot(self, move_line, lot, response_ok_func, response_error_func):
# different, ...)
# * if we have several packages for the same lot, we can't know which
# one the operator is moving, ask to scan a package
- lot_package_quants = self.env["stock.quant"].search(
+ lot_quants = self.env["stock.quant"].search(
[
("lot_id", "=", lot.id),
("location_id", "=", move_line.location_id.id),
- ("package_id", "!=", False),
("quantity", ">", 0),
]
)
- if move_line.package_id and not lot_package_quants:
+ package_quants = lot_quants.filtered(lambda quant: quant.package_id)
+ unit_quants = lot_quants - package_quants
+
+ if len(package_quants) > 1 or (package_quants and unit_quants):
+ # When we can't know which package to take, ask to scan a package.
+ # If we have both units and package, they have to scan the package
+ # first.
return response_error_func(
- move_line, message=self.msg_store.lot_is_not_a_package(lot),
+ move_line,
+ message=self.msg_store.several_packs_in_location(move_line.location_id),
)
- if len(lot_package_quants) == 1:
- package = lot_package_quants.package_id
+ elif len(package_quants) == 1:
+ # change the package directly
+ package = package_quants.package_id
return self.change_package(
move_line, package, response_ok_func, response_error_func
)
- elif len(lot_package_quants) > 1:
- return response_error_func(
- move_line,
- message=self.msg_store.several_packs_in_location(move_line.location_id),
- )
return self._change_pack_lot_change_lot(
move_line, lot, response_ok_func, response_error_func
)
@@ -53,6 +56,9 @@ def change_lot(self, move_line, lot, response_ok_func, response_error_func):
def _change_pack_lot_change_lot(
self, move_line, lot, response_ok_func, response_error_func
):
+ def is_lesser(value, other, rounding):
+ return float_compare(value, other, precision_rounding=rounding) == -1
+
inventory = self.actions_for("inventory")
product = move_line.product_id
if lot.product_id != product:
@@ -61,111 +67,142 @@ def _change_pack_lot_change_lot(
)
previous_lot = move_line.lot_id
# Changing the lot on the move line updates the reservation on the quants
- move_line.lot_id = lot
- message = self.msg_store.lot_replaced_by_lot(previous_lot, lot)
- # check that we are supposed to have enough of this lot in the source location
- quant = lot.quant_ids.filtered(lambda q: q.location_id == move_line.location_id)
- if not quant:
- # not supposed to have this lot here... (if there is a quant
- # but not enough quantity we don't care here: user will report
- # a stock issue)
- inventory.create_control_stock(
- move_line.location_id,
- move_line.product_id,
- move_line.package_id,
- move_line.lot_id,
- _("Pick: stock issue on lot: {} found in {}").format(
- lot.name, move_line.location_id.name
- ),
+ message_parts = []
+
+ values = {"lot_id": lot.id}
+
+ available_quantity = self.env["stock.quant"]._get_available_quantity(
+ product, move_line.location_id, lot_id=lot, strict=True
+ )
+
+ if move_line.package_id:
+ move_line.package_level_id.explode_package()
+ values["package_id"] = False
+
+ to_assign_moves = self.env["stock.move"]
+ if float_is_zero(
+ available_quantity, precision_rounding=product.uom_id.rounding
+ ):
+ quants = self.env["stock.quant"]._gather(
+ product, move_line.location_id, lot_id=lot, strict=True
+ )
+ if quants:
+ # we have quants but they are all reserved by other lines:
+ # unreserve the other lines and reserve them again after
+ unreservable_lines = self.env["stock.move.line"].search(
+ [
+ ("lot_id", "=", lot.id),
+ ("product_id", "=", product.id),
+ ("location_id", "=", move_line.location_id.id),
+ ("qty_done", "=", 0),
+ ]
+ )
+ if not unreservable_lines:
+ return response_error_func(
+ move_line,
+ message=self.msg_store.cannot_change_lot_already_picked(lot),
+ )
+ available_quantity = sum(unreservable_lines.mapped("product_qty"))
+ to_assign_moves = unreservable_lines.move_id
+ # if we leave the package level, it will try to reserve the same
+ # one again
+ unreservable_lines.package_level_id.explode_package()
+ # unreserve qties of other lines
+ unreservable_lines.unlink()
+ else:
+ # * we have *no* quant:
+ # The lot is not found at all, but the user scanned it, which means
+ # it's an error in the stock data! To allow the user to continue,
+ # we post an inventory to add the missing quantity, and a second
+ # draft inventory to check later
+ inventory.create_stock_correction(
+ move_line.move_id,
+ move_line.location_id,
+ self.env["stock.quant.package"].browse(),
+ lot,
+ move_line.product_qty,
+ )
+ inventory.create_control_stock(
+ move_line.location_id,
+ move_line.product_id,
+ move_line.package_id,
+ move_line.lot_id,
+ _("Pick: stock issue on lot: {} found in {}").format(
+ lot.name, move_line.location_id.name
+ ),
+ )
+ message_parts.append(
+ _("A draft inventory has been created for control.")
+ )
+
+ # re-evaluate float_is_zero because we may have changed available_quantity
+ if not float_is_zero(
+ available_quantity, precision_rounding=product.uom_id.rounding
+ ) and is_lesser(
+ available_quantity, move_line.product_qty, product.uom_id.rounding
+ ):
+ new_uom_qty = product.uom_id._compute_quantity(
+ available_quantity, move_line.product_uom_id, rounding_method="HALF-UP"
)
- message["body"] += _(" A draft inventory has been created for control.")
+ values["product_uom_qty"] = new_uom_qty
- return response_ok_func(move_line, message=message)
+ move_line.write(values)
- def _package_identical_move_lines_qty(self, package, move_lines):
- grouped_quants = {}
- for quant in package.quant_ids:
- grouped_quants.setdefault(quant.product_id, 0)
- grouped_quants[quant.product_id] += quant.quantity
+ if "product_uom_qty" in values:
+ # when we change the quantity of the move, the state
+ # will still be "assigned" and be skipped by "_action_assign",
+ # recompute the state to be "partially_available"
+ move_line.move_id._recompute_state()
- grouped_lines = {}
- for move_line in move_lines:
- grouped_lines.setdefault(move_line.product_id, 0)
- grouped_lines[move_line.product_id] += move_line.product_uom_qty
+ # if the new package has less quantities, assign will create new move
+ # lines
+ move_line.move_id._action_assign()
- return grouped_quants == grouped_lines
+ # Find other available goods for the lines which were using the
+ # lot before...
+ to_assign_moves._action_assign()
- def change_package(self, move_line, package, response_ok_func, response_error_func):
- inventory = self.actions_for("inventory")
+ message = self.msg_store.lot_replaced_by_lot(previous_lot, lot)
+ if message_parts:
+ message["body"] = "{} {}".format(message["body"], " ".join(message_parts))
+ return response_ok_func(move_line, message=message)
- package_level = move_line.package_level_id
- # several move lines can be moved by the package level, we'll have
- # to update all of them
- move_lines = package_level.move_line_ids
+ def _package_content_replacement_allowed(self, package, move_line):
+ # we can't replace by a package which doesn't contain the product...
+ return move_line.product_id in package.quant_ids.product_id
- # prevent to replace a package by a package with a different content
- identical_content = self._package_identical_move_lines_qty(package, move_lines)
- if not identical_content:
+ def change_package(self, move_line, package, response_ok_func, response_error_func):
+ # prevent to replace a package by a package that would not satisfy the
+ # move (different product)
+ content_replacement_allowed = self._package_content_replacement_allowed(
+ package, move_line
+ )
+ if not content_replacement_allowed:
return response_error_func(
move_line, message=self.msg_store.package_different_content(package)
)
previous_package = move_line.package_id
- if package.location_id != move_line.location_id:
- # the package has been scanned in the current location so we know its
- # a mistake in the data... fix the quant to move the package here
- inventory.move_package_quants_to_location(package, move_line.location_id)
-
- # search a package level which would already move the scanned package
- reserved_level = (
- self.env["stock.package_level"].search([("package_id", "=", package.id)])
- # not possible to search on state
- .filtered(lambda level: level.state in ("new", "assigned"))
- )
- if reserved_level:
- reserved_level.ensure_one()
- if reserved_level.is_done:
- # Not really supposed to happen: if someone sets is_done, the package
- # should no longer be here! But we have to check this and inform the
- # user in any case.
+ # /!\ be sure to box the side-effects before calling "replace_package"
+ # in the savepoint, as we catch the error, we must be sure that any
+ # change is rollbacked
+ try:
+ with self.env.cr.savepoint():
+ # if no quantity is available in the package, this call will
+ # raise a UserError, which will revert the savepoint
+ move_line.replace_package(package)
+ except exceptions.UserError as err:
return response_error_func(
move_line,
- message=self.msg_store.package_already_picked_by(
- package, reserved_level.picking_id
- ),
+ message=self.msg_store.package_change_error(package, err.name),
)
- # Switch the package with the level which was moving it, as we know
- # that:
- # * only one package level at a time is supposed to move a package
- # * the content of the other package is the same (as we checked the
- # content is the same as the current move lines)
- # * if we left the reserved level with the scanned package, we would
- # have 2 levels for the same package and odoo would unreserve the
- # move lines as soon as we confirm the current moves
- # Considering this, we should be safe to interchange the packages
- if reserved_level:
- # Ignore updates on quant reservation, which would prevent to switch
- # 2 packages between 2 assigned package levels: when writing the
- # package of the second level to the first level, it would unreserve
- # it because the second level is still using the package.
- # But here, we know they both available before and must be available after!
- reserved_level.with_context(bypass_reservation_update=True).replace_package(
- previous_package
- )
- package_level.with_context(bypass_reservation_update=True).replace_package(
- package
+ if previous_package:
+ message = self.msg_store.package_replaced_by_package(
+ previous_package, package
)
else:
- # when we are not switching packages, we expect the quant
- # reservations to be aligned
- package_level.replace_package(package)
-
- return response_ok_func(
- move_line,
- message=self.msg_store.package_replaced_by_package(
- previous_package, package
- ),
- )
+ message = self.msg_store.units_replaced_by_package(package)
+ return response_ok_func(move_line, message=message)
diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py
index c0a32de3fe..a46bee4bd8 100644
--- a/shopfloor/actions/inventory.py
+++ b/shopfloor/actions/inventory.py
@@ -52,37 +52,6 @@ def _create_draft_inventory(self, location, product, name):
}
)
- def move_package_quants_to_location(self, package, dest_location):
- """Create inventories to move a package to a different location
-
- It should be called when the package is - in real life - already in
- the destination. It creates an inventory to remove the package from
- the source location and a second inventory to place the package
- in the destination (to reflect the reality).
-
- The source location is the current location of the package.
- """
- quant_values = []
- # sudo and the key in context activate is_inventory_mode on quants
- quants = package.quant_ids.sudo().with_context(inventory_mode=True)
- for quant in quants:
- quantity = quant.quantity
- quant.inventory_quantity = 0
- quant_values.append(self._quant_move_values(quant, dest_location, quantity))
-
- quant_model = self.env["stock.quant"].sudo().with_context(inventory_mode=True)
- quant_model.create(quant_values)
-
- def _quant_move_values(self, quant, location, quantity):
- return {
- "product_id": quant.product_id.id,
- "inventory_quantity": quantity,
- "location_id": location.id,
- "lot_id": quant.lot_id.id,
- "package_id": quant.package_id.id,
- "owner_id": quant.owner_id.id,
- }
-
def create_control_stock(self, location, product, package, lot, name=None):
"""Create a draft inventory so a user has to check a location
@@ -109,13 +78,17 @@ def create_stock_issue(self, move, location, package, lot):
move, location, package, lot
)
qty_to_keep = sum(other_lines.mapped("product_qty"))
- values = self._stock_issue_inventory_values(
- move, location, package, lot, qty_to_keep
+ self.create_stock_correction(move, location, package, lot, qty_to_keep)
+ move._action_assign()
+
+ def create_stock_correction(self, move, location, package, lot, quantity):
+ """Create an inventory with a forced quantity"""
+ values = self._stock_correction_inventory_values(
+ move, location, package, lot, quantity
)
inventory = self.inventory_model.sudo().create(values)
inventory.action_start()
inventory.action_validate()
- move._action_assign()
def _stock_issue_get_related_move_lines(self, move, location, package, lot):
"""Lookup for all the other moves lines that match given move line"""
@@ -128,7 +101,9 @@ def _stock_issue_get_related_move_lines(self, move, location, package, lot):
]
return self.env["stock.move.line"].search(domain)
- def _stock_issue_inventory_values(self, move, location, package, lot, line_qty):
+ def _stock_correction_inventory_values(
+ self, move, location, package, lot, line_qty
+ ):
name = _(
"{picking.name} stock correction in location {location.name} "
"for {product_desc}"
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index e084794e86..1b5cc7f559 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -432,10 +432,24 @@ def package_already_picked_by(self, package, picking):
).format(package.name, picking.name),
}
- def lot_is_not_a_package(self, lot):
+ def units_replaced_by_package(self, new_package):
+ return {
+ "message_type": "success",
+ "body": _("Units replaced by package {}.").format(new_package.name),
+ }
+
+ def package_change_error(self, package, error_msg):
return {
"message_type": "error",
- "body": _("Lot {} is not a package.").format(lot.name),
+ "body": _("Package {} cannot be used: {} ").format(package.name, error_msg),
+ }
+
+ def cannot_change_lot_already_picked(self, lot):
+ return {
+ "message_type": "error",
+ "body": _("Cannot change to lot {} which is entirely picked.").format(
+ lot.name
+ ),
}
def buffer_complete(self):
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index f089481203..b9146dc8aa 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -1,4 +1,4 @@
-from odoo import _, fields, models
+from odoo import _, exceptions, fields, models
from odoo.exceptions import UserError
from odoo.tools.float_utils import float_compare
@@ -137,3 +137,109 @@ def _split_qty_to_be_done(self, qty_done, split_partial=True, **split_default_va
self.with_context(bypass_reservation_update=True).product_uom_qty = qty_done
return (new_line, "lesser")
return (new_line, "full")
+
+ def replace_package(self, new_package):
+ """Replace a package on an assigned move line"""
+ self.ensure_one()
+
+ # search other move lines which should already pick the scanned package
+ other_reserved_lines = self.env["stock.move.line"].search(
+ [
+ ("package_id", "=", new_package.id),
+ ("state", "in", ("partially_available", "assigned")),
+ ]
+ )
+
+ # we can't change already picked lines
+ unreservable_lines = other_reserved_lines.filtered(
+ lambda line: line.qty_done == 0
+ )
+ to_assign_moves = unreservable_lines.move_id
+
+ # if we leave the package level, it will try to reserve the same
+ # one again
+ unreservable_lines.package_level_id.explode_package()
+ # unreserve qties of other lines
+ unreservable_lines.unlink()
+
+ if new_package.location_id != self.location_id:
+ if new_package.quant_ids.reserved_quantity:
+ # this is a unexpected condition: if we started picking a package
+ # in another location, user should never be able to scan it in
+ # another location, block the operation
+ raise exceptions.UserError(
+ _(
+ "Package {} has been partially picked in another location"
+ ).format(new_package.display_name)
+ )
+ # the package has been scanned in the current location so we know its
+ # a mistake in the data... fix the quant to move the package here
+ new_package.move_package_to_location(self.location_id)
+
+ # several move lines can be moved by the package level, if we change
+ # the package for the current one, we destroy the package level because
+ # we are no longer moving the entire package
+ self.package_level_id.explode_package()
+
+ def is_greater(value, other, rounding):
+ return float_compare(value, other, precision_rounding=rounding) == 1
+
+ def is_lesser(value, other, rounding):
+ return float_compare(value, other, precision_rounding=rounding) == -1
+
+ quant = fields.first(
+ new_package.quant_ids.filtered(
+ lambda quant: quant.product_id == self.product_id
+ and is_greater(
+ quant.quantity,
+ quant.reserved_quantity,
+ quant.product_uom_id.rounding,
+ )
+ )
+ )
+ if not quant:
+ raise exceptions.UserError(
+ _(
+ "Package {} does not contain available product {},"
+ " cannot replace package."
+ ).format(new_package.display_name, self.product_id.display_name)
+ )
+
+ values = {
+ "package_id": new_package.id,
+ "lot_id": quant.lot_id.id,
+ "owner_id": quant.owner_id.id,
+ "result_package_id": False,
+ }
+
+ available_quantity = quant.quantity - quant.reserved_quantity
+ if is_lesser(
+ available_quantity, self.product_qty, quant.product_uom_id.rounding
+ ):
+ new_uom_qty = self.product_id.uom_id._compute_quantity(
+ available_quantity, self.product_uom_id, rounding_method="HALF-UP"
+ )
+ values["product_uom_qty"] = new_uom_qty
+
+ self.write(values)
+
+ # try reassign the move in case we had a partial qty, also, it will
+ # recreate a package level if it applies
+ if "product_uom_qty" in values:
+ # when we change the quantity of the move, the state
+ # will still be "assigned" and be skipped by "_action_assign",
+ # recompute the state to be "partially_available"
+ self.move_id._recompute_state()
+
+ # if the new package has less quantities, assign will create new move
+ # lines
+ self.move_id._action_assign()
+
+ # Find other available goods for the lines which were using the
+ # package before...
+ to_assign_moves._action_assign()
+
+ # computation of the 'state' of the package levels is not
+ # triggered, force it
+ to_assign_moves.move_line_ids.package_level_id.modified(["move_line_ids"])
+ self.package_level_id.modified(["move_line_ids"])
diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py
index a8569cdc3d..84822602e4 100644
--- a/shopfloor/models/stock_package_level.py
+++ b/shopfloor/models/stock_package_level.py
@@ -5,36 +5,15 @@ class StockPackageLevel(models.Model):
_name = "stock.package_level"
_inherit = ["stock.package_level", "shopfloor.priority.postpone.mixin"]
- def replace_package(self, new_package):
- """Replace a package on an assigned package level and related records
-
- The replacement package must have the same properties (same products
- and quantities).
- """
- if self.state not in ("new", "assigned"):
- return
-
- move_lines = self.move_line_ids
- # the write method on stock.move.line updates the reservation on quants
- move_lines.package_id = new_package
- # when a package is set on a line, the destination package is the same
- # by default
- move_lines.result_package_id = new_package
- for quant in new_package.quant_ids:
- for line in move_lines:
- if line.product_id == quant.product_id:
- line.lot_id = quant.lot_id
- line.owner_id = quant.owner_id
-
- self.package_id = new_package
-
def shallow_unlink(self):
"""Unlink but keep the moves
+
A package level has a relation to "move_ids" only when the
package level was created first from the UI and it created
its move.
When we unlink a package level, it deletes the move it created.
But in some cases, we want to keep the move, e.g.:
+
* create a package level from the UI to move a package
* it generates a move for the matching product quantity
* we use a barcode scenario such as cluster or zone picking
diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py
index b768c06280..f45e08e920 100644
--- a/shopfloor/models/stock_quant_package.py
+++ b/shopfloor/models/stock_quant_package.py
@@ -43,3 +43,36 @@ def _constrain_name_unique(self):
for rec in self:
if self.search_count([("name", "=", rec.name), ("id", "!=", rec.id)]):
raise exceptions.UserError(_("Package name must be unique!"))
+
+ def move_package_to_location(self, dest_location):
+ """Create inventories to move a package to a different location
+
+ It should be called when the package is - in real life - already in
+ the destination. It creates an inventory to remove the package from
+ the source location and a second inventory to place the package
+ in the destination (to reflect the reality).
+
+ The source location is the current location of the package.
+ """
+ quant_values = []
+ # sudo and the key in context activate is_inventory_mode on quants
+ quants = self.quant_ids.sudo().with_context(inventory_mode=True)
+ for quant in quants:
+ quantity = quant.quantity
+ quant.inventory_quantity = 0
+ quant_values.append(
+ self._move_package_quant_move_values(quant, dest_location, quantity)
+ )
+
+ quant_model = self.env["stock.quant"].sudo().with_context(inventory_mode=True)
+ quant_model.create(quant_values)
+
+ def _move_package_quant_move_values(self, quant, location, quantity):
+ return {
+ "product_id": quant.product_id.id,
+ "inventory_quantity": quantity,
+ "location_id": location.id,
+ "lot_id": quant.lot_id.id,
+ "package_id": quant.package_id.id,
+ "owner_id": quant.owner_id.id,
+ }
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 5edecae6f6..5cfc44e760 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -354,7 +354,11 @@ def _last_picked_line(self, picking):
"""Get the last line picked and put in a pack for this picking"""
return fields.first(
picking.move_line_ids.filtered(
- lambda l: l.qty_done > 0 and l.result_package_id
+ lambda l: l.qty_done > 0
+ and l.result_package_id
+ # if we are moving the entire package, we shouldn't
+ # add stuff inside it, it's not a new package
+ and l.package_id != l.result_package_id
).sorted(key="write_date", reverse=True)
)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 88e0762ea3..9a92d0c28f 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -869,14 +869,13 @@ def dismiss_package_level(self, location_id, package_level_id):
message=self.msg_store.record_not_found(),
)
move_lines = package_level.move_line_ids
+ package_level.explode_package()
move_lines.write(
{
- "result_package_id": False,
# ensure all the lines in the package are the next ones to be processed
"shopfloor_priority": 1,
}
)
- package_level.unlink()
return self._response_for_start_single(
move_lines.mapped("picking_id"), message=self.msg_store.package_open()
)
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 571ed4a34f..575ef7fba1 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -2,6 +2,7 @@
from . import test_menu
from . import test_openapi
from . import test_profile
+from . import test_actions_change_package_lot
from . import test_actions_data
from . import test_actions_data_detail
from . import test_single_pack_transfer
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 7569e5aa4b..383a9d1068 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -92,6 +92,8 @@ def setUpClass(cls):
cls.data = work.component(usage="data")
with cls.work_on_actions(cls) as work:
cls.data_detail = work.component(usage="data_detail")
+ with cls.work_on_actions(cls) as work:
+ cls.msg_store = work.component(usage="message")
with cls.work_on_services(cls) as work:
cls.schema = work.component(usage="schema")
with cls.work_on_services(cls) as work:
@@ -318,6 +320,32 @@ def _fill_stock_for_moves(
location, product, qty, package=package, lot=lot
)
+ # used by _create_package_in_location
+ PackageContent = namedtuple(
+ "PackageContent",
+ # recordset of the product,
+ # quantity in float
+ # recordset of the lot (optional)
+ "product quantity lot",
+ )
+
+ def _create_package_in_location(self, location, content):
+ """Create a package and quants in a location
+
+ content is a list of PackageContent
+ """
+ package = self.env["stock.quant.package"].create({})
+ for product, quantity, lot in content:
+ self._update_qty_in_location(
+ location, product, quantity, package=package, lot=lot
+ )
+ return package
+
+ def _create_lot(self, product):
+ return self.env["stock.production.lot"].create(
+ {"product_id": product.id, "company_id": self.env.company.id}
+ )
+
class PickingBatchMixin:
diff --git a/shopfloor/tests/test_actions_change_package_lot.py b/shopfloor/tests/test_actions_change_package_lot.py
new file mode 100644
index 0000000000..4f8f4c0852
--- /dev/null
+++ b/shopfloor/tests/test_actions_change_package_lot.py
@@ -0,0 +1,1167 @@
+from odoo.tests.common import Form
+
+from .common import CommonCase
+
+
+class TestActionsChangePackageLot(CommonCase):
+ """Tests covering changing a package on a move line"""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ with cls.work_on_actions(cls) as work:
+ cls.change_package_lot = work.component(usage="change.package.lot")
+
+ @classmethod
+ def setUpClassVars(cls):
+ super().setUpClassVars()
+ cls.wh = cls.env.ref("stock.warehouse0")
+ cls.picking_type = cls.wh.out_type_id
+
+ def _create_picking_with_package_level(self, packages):
+ picking_form = Form(self.env["stock.picking"])
+ picking_form.partner_id = self.customer
+ picking_form.origin = "test"
+ picking_form.picking_type_id = self.picking_type
+ picking_form.location_id = self.stock_location
+ picking_form.location_dest_id = self.packing_location
+ for package in packages:
+ with picking_form.package_level_ids_details.new() as move:
+ move.package_id = package
+ picking = picking_form.save()
+ picking.action_confirm()
+ picking.action_assign()
+ return picking
+
+ def assert_quant_reserved_qty(self, move_line, qty_func, package=None, lot=None):
+ domain = [
+ ("location_id", "=", move_line.location_id.id),
+ ("product_id", "=", move_line.product_id.id),
+ ]
+ if package:
+ domain.append(("package_id", "=", package.id))
+ if lot:
+ domain.append(("lot_id", "=", lot.id))
+ quant = self.env["stock.quant"].search(domain)
+ self.assertEqual(quant.reserved_quantity, qty_func())
+
+ def assert_quant_package_qty(self, location, package, qty_func):
+ quant = self.env["stock.quant"].search(
+ [("location_id", "=", location.id), ("package_id", "=", package.id)]
+ )
+ self.assertEqual(quant.quantity, qty_func())
+
+ def assert_control_stock_inventory(self, location, product, lot):
+ inventory = self.env["stock.inventory"].search([], order="id desc", limit=1)
+ self.assertRecordValues(
+ inventory,
+ [
+ {
+ "state": "draft",
+ "product_ids": product.ids,
+ "name": "Pick: stock issue on lot: {} found in {}".format(
+ lot.name, location.name
+ ),
+ }
+ ],
+ )
+
+ @staticmethod
+ def unreachable_func(move_line, message=None):
+ raise AssertionError("should not reach this function")
+
+ def test_change_lot_ok(self):
+ initial_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot)
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ source_location = line.location_id
+ new_lot = self._create_lot(self.product_a)
+ # ensure we have our new package in the same location
+ self._update_qty_in_location(source_location, line.product_id, 10, lot=new_lot)
+ self.change_package_lot.change_lot(
+ line,
+ new_lot,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(
+ message, self.msg_store.lot_replaced_by_lot(initial_lot, new_lot)
+ ),
+ # failure callback
+ self.unreachable_func,
+ )
+ self.assertRecordValues(line, [{"lot_id": new_lot.id}])
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot)
+ self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot)
+
+ def test_change_lot_less_quantity_ok(self):
+ initial_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot)
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ source_location = line.location_id
+ new_lot = self._create_lot(self.product_a)
+ # ensure we have our new package in the same location
+ self._update_qty_in_location(source_location, line.product_id, 8, lot=new_lot)
+ self.change_package_lot.change_lot(
+ line,
+ new_lot,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(
+ message, self.msg_store.lot_replaced_by_lot(initial_lot, new_lot)
+ ),
+ # failure callback
+ self.unreachable_func,
+ )
+ self.assertRecordValues(line, [{"lot_id": new_lot.id, "product_qty": 8}])
+ other_line = line.move_id.move_line_ids - line
+ self.assertRecordValues(
+ other_line, [{"lot_id": initial_lot.id, "product_qty": 2}]
+ )
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 2, lot=initial_lot)
+ self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot)
+
+ def test_change_lot_zero_quant_ok(self):
+ """No quant in the location for the scanned lot
+
+ As the user scanned it, it's an inventory error.
+ We expect a new posted inventory that updates the quantity.
+ And another control one.
+ """
+ initial_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot)
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ new_lot = self._create_lot(self.product_a)
+ expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot)
+ expected_message["body"] += " A draft inventory has been created for control."
+ self.change_package_lot.change_lot(
+ line,
+ new_lot,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(message, expected_message),
+ # failure callback
+ self.unreachable_func,
+ )
+
+ self.assertRecordValues(line, [{"lot_id": new_lot.id, "product_qty": 10}])
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot)
+ self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot)
+
+ def test_change_lot_package_explode_ok(self):
+ """Scan a lot on units replacing a package"""
+ initial_lot = self._create_lot(self.product_a)
+ package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)]
+ )
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ self.assertEqual(line.lot_id, initial_lot)
+ self.assertEqual(line.package_id, package)
+
+ new_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=new_lot)
+ expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot)
+ self.change_package_lot.change_lot(
+ line,
+ new_lot,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(message, expected_message),
+ # failure callback
+ self.unreachable_func,
+ )
+
+ self.assertRecordValues(
+ line,
+ [
+ {
+ "lot_id": new_lot.id,
+ "product_qty": 10,
+ "package_id": False,
+ "package_level_id": False,
+ }
+ ],
+ )
+
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot)
+ self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot)
+
+ def test_change_lot_reserved_qty_ok(self):
+ """Scan a lot already reserved by other lines
+
+ It should unreserve the other line, use the lot for the current line,
+ and re-reserve the other move.
+ """
+ initial_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot)
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ self.assertEqual(line.lot_id, initial_lot)
+
+ new_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=new_lot)
+ picking2 = self._create_picking(lines=[(self.product_a, 10)])
+ picking2.action_assign()
+ line2 = picking2.move_line_ids
+ self.assertEqual(line2.lot_id, new_lot)
+
+ expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot)
+ self.change_package_lot.change_lot(
+ line,
+ new_lot,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(message, expected_message),
+ # failure callback
+ self.unreachable_func,
+ )
+
+ self.assertRecordValues(line, [{"lot_id": new_lot.id, "product_qty": 10}])
+ # line has been re-created
+ line2 = picking2.move_line_ids
+ self.assertRecordValues(line2, [{"lot_id": initial_lot.id, "product_qty": 10}])
+
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot)
+ self.assert_quant_reserved_qty(
+ line2, lambda: line2.product_qty, lot=initial_lot
+ )
+
+ def test_change_lot_reserved_partial_qty_ok(self):
+ """Scan a lot already reserved by other lines and can only be reserved
+ partially
+
+ It should unreserve the other line, use the lot for the current line,
+ and re-reserve the other move. The quantity for the current line must
+ be adapted to the available
+ """
+ initial_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot)
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ self.assertEqual(line.lot_id, initial_lot)
+
+ new_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 8, lot=new_lot)
+ picking2 = self._create_picking(lines=[(self.product_a, 8)])
+ picking2.action_assign()
+ line2 = picking2.move_line_ids
+ self.assertEqual(line2.lot_id, new_lot)
+
+ expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot)
+ self.change_package_lot.change_lot(
+ line,
+ new_lot,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(message, expected_message),
+ # failure callback
+ self.unreachable_func,
+ )
+
+ self.assertRecordValues(line, [{"lot_id": new_lot.id, "product_qty": 8}])
+ other_line = picking.move_line_ids - line
+ self.assertRecordValues(
+ other_line, [{"lot_id": initial_lot.id, "product_qty": 2}]
+ )
+ # line has been re-created
+ line2 = picking2.move_line_ids
+ self.assertRecordValues(line2, [{"lot_id": initial_lot.id, "product_qty": 8}])
+
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot)
+ # both line2 and the line for the 2 remaining will re-reserve the initial lot
+ self.assert_quant_reserved_qty(
+ other_line,
+ lambda: line2.product_qty + other_line.product_qty,
+ lot=initial_lot,
+ )
+
+ def test_change_lot_reserved_qty_done_error(self):
+ """Scan a lot already reserved by other *picked* lines
+
+ Cannot "steal" lot from picked lines
+ """
+ initial_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot)
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ self.assertEqual(line.lot_id, initial_lot)
+
+ new_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=new_lot)
+ picking2 = self._create_picking(lines=[(self.product_a, 10)])
+ picking2.action_assign()
+ line2 = picking2.move_line_ids
+ self.assertEqual(line2.lot_id, new_lot)
+ line2.qty_done = 10.0
+
+ expected_message = self.msg_store.cannot_change_lot_already_picked(new_lot)
+ self.change_package_lot.change_lot(
+ line,
+ new_lot,
+ # success callback
+ self.unreachable_func,
+ # failure callback
+ lambda move_line, message=None: self.assertEqual(message, expected_message),
+ )
+
+ # no changes
+ self.assertRecordValues(line, [{"lot_id": initial_lot.id, "product_qty": 10}])
+ self.assertRecordValues(
+ line2, [{"lot_id": new_lot.id, "product_qty": 10, "qty_done": 10.0}]
+ )
+ self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=initial_lot)
+ self.assert_quant_reserved_qty(line2, lambda: line2.product_qty, lot=new_lot)
+
+ def test_change_lot_different_location_ok(self):
+ self.product_a.tracking = "lot"
+ initial_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot)
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ new_lot = self._create_lot(self.product_a)
+ # ensure we have our new package in a different location
+ self._update_qty_in_location(self.shelf2, line.product_id, 10, lot=new_lot)
+ expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot)
+ expected_message["body"] += " A draft inventory has been created for control."
+ self.change_package_lot.change_lot(
+ line,
+ new_lot,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(message, expected_message),
+ # failure callback
+ self.unreachable_func,
+ )
+
+ self.assertRecordValues(line, [{"lot_id": new_lot.id}])
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot)
+ self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot)
+ self.assert_control_stock_inventory(self.shelf1, line.product_id, new_lot)
+
+ def test_change_lot_in_several_packages_error(self):
+ self.product_a.tracking = "lot"
+ initial_lot = self._create_lot(self.product_a)
+ self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)]
+ )
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ # create 2 packages for the same new lot in the same location
+ new_lot = self._create_lot(self.product_a)
+ self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)]
+ )
+ self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)]
+ )
+ self.change_package_lot.change_lot(
+ line,
+ new_lot,
+ # success callback
+ self.unreachable_func,
+ # failure callback
+ lambda move_line, message=None: self.assertEqual(
+ message, self.msg_store.several_packs_in_location(self.shelf1)
+ ),
+ )
+
+ def test_change_lot_in_package_ok(self):
+ self.product_a.tracking = "lot"
+ initial_lot = self._create_lot(self.product_a)
+ initial_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)]
+ )
+ # ensure we have our new package in the same location
+ new_lot = self._create_lot(self.product_a)
+ new_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=new_lot)]
+ )
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ self.change_package_lot.change_lot(
+ line,
+ new_lot,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(
+ message,
+ self.msg_store.package_replaced_by_package(
+ initial_package, new_package
+ ),
+ ),
+ # failure callback
+ self.unreachable_func,
+ )
+ self.assertRecordValues(
+ line,
+ [
+ {
+ "package_id": new_package.id,
+ "result_package_id": new_package.id,
+ "lot_id": new_lot.id,
+ "product_qty": 10.0,
+ }
+ ],
+ )
+ self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
+ self.assert_quant_reserved_qty(
+ line, lambda: line.product_qty, package=new_package
+ )
+
+ def test_change_lot_in_package_no_initial_package_ok(self):
+ self.product_a.tracking = "lot"
+ initial_lot = self._create_lot(self.product_a)
+ self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot)
+ # ensure we have our new package in the same location
+ new_lot = self._create_lot(self.product_a)
+ new_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=new_lot)]
+ )
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ self.change_package_lot.change_lot(
+ line,
+ new_lot,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(
+ message, self.msg_store.units_replaced_by_package(new_package)
+ ),
+ # failure callback
+ self.unreachable_func,
+ )
+ self.assertRecordValues(
+ line,
+ [
+ {
+ "package_id": new_package.id,
+ "result_package_id": new_package.id,
+ "lot_id": new_lot.id,
+ "product_qty": 10.0,
+ }
+ ],
+ )
+ self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot)
+ self.assert_quant_reserved_qty(
+ line, lambda: line.product_qty, package=new_package
+ )
+
+ def test_change_pack_different_content_error(self):
+ # create the initial package, that will be reserved first
+ initial_package = self._create_package_in_location(
+ self.shelf1,
+ [
+ self.PackageContent(self.product_a, 10, lot=None),
+ self.PackageContent(self.product_b, 10, lot=None),
+ ],
+ )
+ picking = self._create_picking_with_package_level(initial_package)
+ # create a new package in the same location
+ # with a different content
+ new_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_b, 8, lot=None)]
+ )
+
+ lines = picking.move_line_ids
+ # try to use the new package, which doesn't contain our product,
+ # cannot be changed
+ self.change_package_lot.change_package(
+ lines[0],
+ new_package,
+ # success callback
+ self.unreachable_func,
+ # failure callback
+ lambda move_line, message=None: self.assertEqual(
+ message, self.msg_store.package_different_content(new_package)
+ ),
+ )
+
+ def test_change_pack_multi_content_with_lot(self):
+ """Switch package for a line which was part of a multi-products package
+
+ We have a move line which is part of a package with more than one
+ product and the other product is moved by another move line.
+
+ We want to pick the goods for product A in a different package. What
+ should happen is:
+
+ * the package level is exploded, as we will no longer move the entire
+ package
+ * the move line for product A should now use the new package, and be
+ updated with the lot of the package
+ * the move line for the other product should keep the other package, if
+ the user want to change the package for the other product too, they
+ can do it when they pick it
+ """
+ (self.product_a + self.product_b).tracking = "lot"
+ # create a package with 2 products tracked by lot, stored in shelf1
+ # this package is reserved first on the move line
+ initial_lot_a = self._create_lot(self.product_a)
+ initial_lot_b = self._create_lot(self.product_b)
+ initial_package = self._create_package_in_location(
+ self.shelf1,
+ [
+ self.PackageContent(self.product_a, 10, initial_lot_a),
+ self.PackageContent(self.product_b, 10, initial_lot_b),
+ ],
+ )
+
+ # create and reserve our transfer using the initial package
+ picking = self._create_picking_with_package_level(initial_package)
+
+ lines = picking.move_line_ids
+
+ # create a second package with the same content, which will be used
+ # as replacement
+ new_lot_a = self._create_lot(self.product_a)
+ new_lot_b = self._create_lot(self.product_b)
+ new_package = self._create_package_in_location(
+ self.shelf1,
+ [
+ self.PackageContent(self.product_a, 10, new_lot_a),
+ self.PackageContent(self.product_b, 10, new_lot_b),
+ ],
+ )
+ line1, line2 = lines
+ self.change_package_lot.change_package(
+ line1,
+ new_package,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(
+ message,
+ self.msg_store.package_replaced_by_package(
+ initial_package, new_package
+ ),
+ ),
+ # failure callback
+ self.unreachable_func,
+ )
+ self.assertRecordValues(
+ line1,
+ [
+ {
+ "package_id": new_package.id,
+ # we are no longer moving an entire package
+ "result_package_id": False,
+ "lot_id": new_lot_a.id,
+ "product_qty": 10.0,
+ }
+ ],
+ )
+ self.assertRecordValues(
+ line2,
+ [
+ {
+ "package_id": initial_package.id,
+ # we are no longer moving an entire package
+ "result_package_id": False,
+ "lot_id": initial_lot_b.id,
+ "product_qty": 10.0,
+ }
+ ],
+ )
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line1, lambda: 0, package=initial_package)
+ self.assert_quant_reserved_qty(
+ line2, lambda: line2.product_qty, package=initial_package
+ )
+ self.assert_quant_reserved_qty(
+ line1, lambda: line1.product_qty, package=new_package
+ )
+ self.assert_quant_reserved_qty(line2, lambda: 0, package=new_package)
+
+ def test_change_pack_different_location(self):
+ initial_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+ # put a package in shelf2 in the system, but we assume that in real,
+ # the operator put it in shelf1
+ new_package = self._create_package_in_location(
+ self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ # when the operator wants to pick the initial package, in shelf1, the new
+ # package is in front of the other so they want to change the package
+ self.change_package_lot.change_package(
+ line,
+ new_package,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(
+ message,
+ self.msg_store.package_replaced_by_package(
+ initial_package, new_package
+ ),
+ ),
+ # failure callback
+ self.unreachable_func,
+ )
+
+ self.assertRecordValues(
+ line, [{"package_id": new_package.id, "result_package_id": new_package.id}]
+ )
+ self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
+ # check that reservations have been updated, the new package is not
+ # supposed to be in shelf2 anymore, and we should have no reserved qty
+ # for the initial package anymore
+ self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0)
+ self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
+ self.assert_quant_reserved_qty(
+ line, lambda: line.product_qty, package=new_package
+ )
+
+ def test_change_pack_different_location_reserved_package(self):
+ initial_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ self.assertEqual(line.package_id, initial_package)
+
+ # put a package in shelf2 in the system, but we assume that in real,
+ # the operator put it in shelf1
+ new_package = self._create_package_in_location(
+ self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+ picking2 = self._create_picking(lines=[(self.product_a, 10)])
+ picking2.action_assign()
+ line2 = picking2.move_line_ids
+ self.assertEqual(line2.package_id, new_package)
+
+ # When the operator wants to pick the initial package, in shelf1, the new
+ # package is in front of the other so they want to change the package.
+ # The new package was supposed to be in shelf2 but is in fact in
+ # shelf1.
+ # An inventory must move it in shelf1 before we change the package on the line.
+ # Line2 must be unreserved and reserved again.
+ self.change_package_lot.change_package(
+ line,
+ new_package,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(
+ message,
+ self.msg_store.package_replaced_by_package(
+ initial_package, new_package
+ ),
+ ),
+ # failure callback
+ self.unreachable_func,
+ )
+
+ # line2 has been re-created
+ line2 = picking2.move_line_ids
+ self.assertRecordValues(
+ line + line2,
+ [
+ {
+ "package_id": new_package.id,
+ "result_package_id": new_package.id,
+ "location_id": self.shelf1.id,
+ "product_qty": 10.0,
+ },
+ {
+ "package_id": initial_package.id,
+ "result_package_id": initial_package.id,
+ "location_id": self.shelf1.id,
+ "product_qty": 10.0,
+ },
+ ],
+ )
+ self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
+ self.assertRecordValues(
+ line2.package_level_id, [{"package_id": initial_package.id}]
+ )
+ # check that reservations have been updated, the new package is not
+ # supposed to be in shelf2 anymore, and we should have no reserved qty
+ # for the initial package anymore
+ self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0)
+ self.assert_quant_reserved_qty(
+ line, lambda: line.product_qty, package=new_package
+ )
+ self.assert_quant_reserved_qty(
+ line2, lambda: line2.product_qty, package=initial_package
+ )
+
+ def test_change_pack_different_location_reserved_package_qty_done(self):
+ initial_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ self.assertEqual(line.package_id, initial_package)
+
+ # put a package in shelf2 in the system, but we assume that in real,
+ # the operator put it in shelf1
+ new_package = self._create_package_in_location(
+ self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+ picking2 = self._create_picking(lines=[(self.product_a, 10)])
+ picking2.action_assign()
+ line2 = picking2.move_line_ids
+ self.assertEqual(line2.package_id, new_package)
+ line2.qty_done = 10.0
+
+ # The new package was supposed to be in shelf2 but is in fact in shelf1.
+ # The package has already been picked in shelf2 (unlikely to happen...
+ # still we have to handle it). Forbid to pick.
+ expected_message = self.msg_store.package_change_error(
+ new_package,
+ "Package {} has been partially picked in another location".format(
+ new_package.display_name, line.product_id.display_name
+ ),
+ )
+ self.change_package_lot.change_package(
+ line,
+ new_package,
+ # success callback
+ self.unreachable_func,
+ # failure callback
+ lambda move_line, message=None: self.assertEqual(message, expected_message),
+ )
+
+ # line2 has been re-created
+ line2 = picking2.move_line_ids
+ self.assertRecordValues(
+ line + line2,
+ [
+ {
+ "package_id": initial_package.id,
+ "result_package_id": initial_package.id,
+ "location_id": self.shelf1.id,
+ "product_qty": 10.0,
+ },
+ {
+ "package_id": new_package.id,
+ "result_package_id": new_package.id,
+ "location_id": self.shelf2.id,
+ "product_qty": 10.0,
+ },
+ ],
+ )
+ # no change
+ self.assertRecordValues(
+ line.package_level_id, [{"package_id": initial_package.id}]
+ )
+ self.assertRecordValues(
+ line2.package_level_id, [{"package_id": new_package.id}]
+ )
+ self.assert_quant_package_qty(self.shelf2, new_package, lambda: 10.0)
+ self.assert_quant_reserved_qty(
+ line, lambda: line.product_qty, package=initial_package
+ )
+ self.assert_quant_reserved_qty(
+ line2, lambda: line2.product_qty, package=new_package
+ )
+
+ def test_change_pack_lot_change_pack_less_qty_ok(self):
+ initial_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 100, lot=None)]
+ )
+
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+
+ self.assertRecordValues(
+ line,
+ [
+ {
+ "package_id": initial_package.id,
+ # since we don't move the entire package (10 out of 100), no
+ # result package
+ "result_package_id": False,
+ "product_qty": 10.0,
+ }
+ ],
+ )
+ self.assertFalse(line.package_level_id)
+
+ # ensure we have our new package in the same location
+ new_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+ self.change_package_lot.change_package(
+ line,
+ new_package,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(
+ message,
+ self.msg_store.package_replaced_by_package(
+ initial_package, new_package
+ ),
+ ),
+ # failure callback
+ self.unreachable_func,
+ )
+ self.assertRecordValues(
+ line,
+ [
+ {
+ "package_id": new_package.id,
+ "result_package_id": new_package.id,
+ "product_qty": 10.0,
+ }
+ ],
+ )
+ self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
+
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
+ self.assert_quant_reserved_qty(
+ line, lambda: line.product_qty, package=new_package
+ )
+
+ def test_change_pack_steal_from_other_move_line(self):
+ """Exchange pack with another line
+
+ When we scan the package used on another line not picked yet (qty_done
+ == 0), we unreserve the other line and use its package. The other line
+ is reserved again and should reserve the package used initially on our
+ move line.
+ """
+ # create 2 picking, each with its own package
+ package1 = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+ picking1 = self._create_picking_with_package_level(package1)
+ self.assertEqual(picking1.move_line_ids.package_id, package1)
+
+ package2 = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+ picking2 = self._create_picking_with_package_level(package2)
+ self.assertEqual(picking2.move_line_ids.package_id, package2)
+
+ line = picking1.move_line_ids
+
+ # We "steal" package2 for the picking1
+ self.change_package_lot.change_package(
+ line,
+ package2,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(
+ message, self.msg_store.package_replaced_by_package(package1, package2)
+ ),
+ # failure callback
+ self.unreachable_func,
+ )
+
+ self.assertRecordValues(
+ picking1.move_line_ids,
+ [
+ {
+ "package_id": package2.id,
+ "result_package_id": package2.id,
+ "state": "assigned",
+ "product_qty": 10.0,
+ }
+ ],
+ )
+ self.assertRecordValues(
+ picking2.move_line_ids,
+ [
+ {
+ "package_id": package1.id,
+ "result_package_id": package1.id,
+ "state": "assigned",
+ "product_qty": 10.0,
+ }
+ ],
+ )
+ self.assertRecordValues(
+ picking1.package_level_ids,
+ [{"package_id": package2.id, "state": "assigned"}],
+ )
+ self.assertRecordValues(
+ picking2.package_level_ids,
+ [{"package_id": package1.id, "state": "assigned"}],
+ )
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(
+ picking1.move_line_ids,
+ lambda: picking1.move_line_ids.product_qty,
+ package=package2,
+ )
+ self.assert_quant_reserved_qty(
+ picking2.move_line_ids,
+ lambda: picking2.move_line_ids.product_qty,
+ package=package1,
+ )
+
+ def test_other_line_with_qty_done(self):
+ """Try to exchange pack with other line with qty_done
+
+ When we scan the package used on another line which has been picked
+ (qty_done > 0), do not unreserve the other line.
+ """
+ # create 2 picking, each with its own package
+ package1 = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+ picking1 = self._create_picking_with_package_level(package1)
+ self.assertEqual(picking1.move_line_ids.package_id, package1)
+
+ package2 = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+ picking2 = self._create_picking_with_package_level(package2)
+ self.assertEqual(picking2.move_line_ids.package_id, package2)
+
+ line1 = picking1.move_line_ids
+ line2 = picking2.move_line_ids
+ line2.qty_done = 10
+
+ self.change_package_lot.change_package(
+ line1,
+ package2,
+ # success callback
+ self.unreachable_func,
+ # failure callback
+ lambda move_line, message=None: self.assertEqual(
+ message,
+ self.msg_store.package_change_error(
+ package2,
+ "Package {} does not contain available product {},"
+ " cannot replace package.".format(
+ package2.display_name, line1.product_id.display_name
+ ),
+ ),
+ ),
+ )
+
+ # did not change
+ self.assertRecordValues(
+ picking1.move_line_ids,
+ [
+ {
+ "package_id": package1.id,
+ "result_package_id": package1.id,
+ "state": "assigned",
+ }
+ ],
+ )
+ self.assertRecordValues(
+ picking2.move_line_ids,
+ [
+ {
+ "package_id": package2.id,
+ "result_package_id": package2.id,
+ "state": "assigned",
+ }
+ ],
+ )
+ self.assertRecordValues(
+ picking1.package_level_ids,
+ [{"package_id": package1.id, "state": "assigned"}],
+ )
+ self.assertRecordValues(
+ picking2.package_level_ids,
+ [{"package_id": package2.id, "state": "assigned"}],
+ )
+ # check that reservations have been updated
+ self.assert_quant_reserved_qty(
+ picking1.move_line_ids,
+ lambda: picking1.move_line_ids.product_qty,
+ package=package1,
+ )
+ self.assert_quant_reserved_qty(
+ picking2.move_line_ids,
+ lambda: picking2.move_line_ids.product_qty,
+ package=package2,
+ )
+
+ def test_package_partial(self):
+ """Try to exchange pack with a package partially picked
+
+ When we scan the package used on another line which has been picked
+ (qty_done > 0), but the new package still has unreserved quantity:
+
+ * the current line is updated for the remaining unreserved quantity
+ * a new line is created for the remaining
+ * the other already picked line is untouched
+ """
+ # create 2 picking, each with its own package
+ package1 = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+ picking1 = self._create_picking_with_package_level(package1)
+ line1 = picking1.move_line_ids
+ self.assertEqual(line1.package_id, package1)
+
+ package2 = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
+ )
+
+ # take partially in package2 (no package level as moving partial
+ # package)
+ picking2 = self._create_picking(lines=[(self.product_a, 8)])
+ picking2.action_assign()
+ line2 = picking2.move_line_ids
+ self.assertEqual(line2.package_id, package2)
+
+ # this line is picked, should not be changed, but we still have
+ # 2 units in package2
+ line2.qty_done = line2.product_qty
+
+ self.change_package_lot.change_package(
+ line1,
+ package2,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(
+ message, self.msg_store.package_replaced_by_package(package1, package2)
+ ),
+ # failure callback
+ self.unreachable_func,
+ )
+
+ self.assertRecordValues(
+ line1,
+ [
+ {
+ "package_id": package2.id,
+ # not moved entirely by this transfer
+ "result_package_id": False,
+ "state": "assigned",
+ # as the remaining was 2 units, the line is
+ # changed to take only 2
+ "product_qty": 2.0,
+ }
+ ],
+ )
+ self.assertRecordValues(
+ # this line should be unchanged
+ line2,
+ [
+ {
+ "package_id": package2.id,
+ # not moved entirely by this transfer
+ "result_package_id": False,
+ "state": "assigned",
+ "product_qty": 8.0,
+ }
+ ],
+ )
+
+ # A new line has been created for the quantity the line1
+ # couldn't take in package2. It will take the first goods
+ # available, which happen to be package1 (which was unreserved
+ # when we changed the package of line1).
+ remaining_line = picking1.move_line_ids - line1
+ self.assertRecordValues(
+ remaining_line,
+ [
+ {
+ "package_id": package1.id,
+ # not moved entirely by this transfer
+ "result_package_id": False,
+ "state": "assigned",
+ # remaining qty for the 1st move
+ "product_qty": 8.0,
+ }
+ ],
+ )
+
+ # the package1 must have only 8 reserved, for the remaining
+ # of the line
+ self.assertEqual(package1.quant_ids.reserved_quantity, 8)
+ self.assertEqual(package2.quant_ids.reserved_quantity, 10)
+
+ # no package is moved entirely at once
+ self.assertFalse(picking1.package_level_ids)
+ self.assertFalse(picking2.package_level_ids)
+
+ def test_package_2_lines_1_move(self):
+ """Keep picked move line if we have 2 lines on a move
+
+ Create a situation where we have 2 move lines on a move, with different
+ packages, 1 one of them is already picked (qty_done > 0), we change the
+ package on the second one: the first one must not be changed.
+ """
+ package1 = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 4, lot=None)]
+ )
+ package2 = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 8, lot=None)]
+ )
+
+ # take partially in package2 (no package level as moving partial
+ # package)
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ move = picking.move_lines
+ line1, line2 = move.move_line_ids
+ self.assertEqual(line1.package_id, package1)
+ self.assertEqual(line2.package_id, package2)
+
+ # package to switch to
+ package3 = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 8, lot=None)]
+ )
+
+ # this line is picked and must not be changed
+ line1.qty_done = line1.product_qty
+
+ # as we change for package2, the line should get only the remaining
+ # part of the package
+
+ self.change_package_lot.change_package(
+ line2,
+ package3,
+ # success callback
+ lambda move_line, message=None: self.assertEqual(
+ message, self.msg_store.package_replaced_by_package(package2, package3)
+ ),
+ # failure callback
+ self.unreachable_func,
+ )
+
+ self.assertRecordValues(
+ line1 | line2,
+ [
+ {
+ "package_id": package1.id,
+ "state": "assigned",
+ "product_qty": 4.0,
+ "qty_done": 4.0,
+ },
+ {
+ "package_id": package3.id,
+ "state": "assigned",
+ "product_qty": 6.0,
+ "qty_done": 0.0,
+ },
+ ],
+ )
+
+ # package1 is moved entirely
+ self.assertTrue(line1.package_level_id)
+ # package2 is not moved entirely
+ self.assertFalse(line2.package_level_id)
+
+ # the package1 must have only 8 reserved, for the remaining
+ # of the line
+ self.assertEqual(package1.quant_ids.reserved_quantity, 4)
+ self.assertEqual(package2.quant_ids.reserved_quantity, 0)
+ self.assertEqual(package3.quant_ids.reserved_quantity, 6)
diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
index 1f924f10b3..190b8f9c41 100644
--- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py
+++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
@@ -1,36 +1,18 @@
-from collections import namedtuple
-
-from odoo.tests.common import Form
-
from .test_cluster_picking_base import ClusterPickingCommonCase
-class ClusterPickingChangePackLotCommon(ClusterPickingCommonCase):
-
- # used by _create_package_in_location
- PackageContent = namedtuple(
- "PackageContent",
- # recordset of the product,
- # quantity in float
- # recordset of the lot (optional)
- "product quantity lot",
- )
-
- def _create_package_in_location(self, location, content):
- """Create a package and quants in a location
+class ClusterPickingChangePackLotCase(ClusterPickingCommonCase):
+ """Tests covering the /change_pack_lot endpoint
- content is a list of PackageContent
- """
- package = self.env["stock.quant.package"].create({})
- for product, quantity, lot in content:
- self._update_qty_in_location(
- location, product, quantity, package=package, lot=lot
- )
- return package
+ Only simple cases are tested to check the flow of responses on success and
+ error, the "change.package.lot" component is tested in its own tests.
+ """
- def _create_lot(self, product):
- return self.env["stock.production.lot"].create(
- {"product_id": product.id, "company_id": self.env.company.id}
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.batch = cls._create_picking_batch(
+ [[cls.BatchProduct(product=cls.product_a, quantity=10)]]
)
def _test_change_pack_lot(self, line, barcode, success=True, message=None):
@@ -57,62 +39,8 @@ def _test_change_pack_lot(self, line, barcode, success=True, message=None):
next_state="change_pack_lot",
data=self._line_data(line),
)
-
- def _skip_line(self, line, next_line=None):
- batch = line.picking_id.batch_id
- response = self.service.dispatch(
- "skip_line", params={"picking_batch_id": batch.id, "move_line_id": line.id}
- )
- if next_line:
- self.assert_response(
- response, next_state="start_line", data=self._line_data(next_line)
- )
return response
- def assert_quant_reserved_qty(self, move_line, qty_func, package=None, lot=None):
- domain = [
- ("location_id", "=", move_line.location_id.id),
- ("product_id", "=", move_line.product_id.id),
- ]
- if package:
- domain.append(("package_id", "=", package.id))
- if lot:
- domain.append(("lot_id", "=", lot.id))
- quant = self.env["stock.quant"].search(domain)
- self.assertEqual(quant.reserved_quantity, qty_func())
-
- def assert_quant_package_qty(self, location, package, qty_func):
- quant = self.env["stock.quant"].search(
- [("location_id", "=", location.id), ("package_id", "=", package.id)]
- )
- self.assertEqual(quant.quantity, qty_func())
-
- def assert_control_stock_inventory(self, location, product, lot):
- inventory = self.env["stock.inventory"].search([], order="id desc", limit=1)
- self.assertRecordValues(
- inventory,
- [
- {
- "state": "draft",
- "product_ids": product.ids,
- "name": "Pick: stock issue on lot: {} found in {}".format(
- lot.name, location.name
- ),
- },
- ],
- )
-
-
-class ClusterPickingChangePackLotCase(ClusterPickingChangePackLotCommon):
- """Tests covering the /change_pack_lot endpoint"""
-
- @classmethod
- def setUpClassBaseData(cls, *args, **kwargs):
- super().setUpClassBaseData(*args, **kwargs)
- cls.batch = cls._create_picking_batch(
- [[cls.BatchProduct(product=cls.product_a, quantity=10)]]
- )
-
def test_change_pack_lot_change_pack_ok(self):
initial_package = self._create_package_in_location(
self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
@@ -134,138 +62,17 @@ def test_change_pack_lot_change_pack_ok(self):
),
)
- self.assertRecordValues(
- line, [{"package_id": new_package.id, "result_package_id": new_package.id}]
- )
-
- self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
- # check that reservations have been updated
- self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
- self.assert_quant_reserved_qty(
- line, lambda: line.product_qty, package=new_package
- )
-
- def test_change_pack_lot_change_pack_different_location(self):
- initial_package = self._create_package_in_location(
- self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)]
- )
-
- # initial_package from shelf1 will be selected in our move line
- self._simulate_batch_selected(self.batch, fill_stock=False)
-
- # put a package in shelf2 in the system, but we assume that in real,
- # the operator put it in shelf1
- new_package = self._create_package_in_location(
- self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)]
- )
-
- line = self.batch.picking_ids.move_line_ids
- # when the operator wants to pick the initial package, in shelf1, the new
- # package is in front of the other so they want to change the package
- self._test_change_pack_lot(
- line,
- new_package.name,
- success=True,
- message=self.service.msg_store.package_replaced_by_package(
- initial_package, new_package
- ),
- )
-
- self.assertRecordValues(
- line, [{"package_id": new_package.id, "result_package_id": new_package.id}]
- )
- self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
- # check that reservations have been updated, the new package is not
- # supposed to be in shelf2 anymore, and we should have no reserved qty
- # for the initial package anymore
- self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0)
- self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
- self.assert_quant_reserved_qty(
- line, lambda: line.product_qty, package=new_package
- )
-
- def test_change_pack_lot_change_lot_in_package_ok(self):
- self.product_a.tracking = "lot"
- initial_lot = self._create_lot(self.product_a)
- initial_package = self._create_package_in_location(
- self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)]
- )
- self._simulate_batch_selected(self.batch, fill_stock=False)
- # ensure we have our new package in the same location
- new_lot = self._create_lot(self.product_a)
- new_package = self._create_package_in_location(
- self.shelf1, [self.PackageContent(self.product_a, 10, lot=new_lot)]
- )
- line = self.batch.picking_ids.move_line_ids
- self._test_change_pack_lot(
- line,
- new_lot.name,
- success=True,
- message=self.service.msg_store.package_replaced_by_package(
- initial_package, new_package
- ),
- )
-
self.assertRecordValues(
line,
[
{
"package_id": new_package.id,
"result_package_id": new_package.id,
- "lot_id": new_lot.id,
+ "product_qty": 10.0,
}
],
)
self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}])
- # check that reservations have been updated
- self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
- self.assert_quant_reserved_qty(
- line, lambda: line.product_qty, package=new_package
- )
-
- def test_change_pack_lot_change_lot_in_several_packages_error(self):
- self.product_a.tracking = "lot"
- initial_lot = self._create_lot(self.product_a)
- self._create_package_in_location(
- self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)]
- )
- self._simulate_batch_selected(self.batch, fill_stock=False)
- line = self.batch.picking_ids.move_line_ids
- # create 2 packages for the same new lot in the same location
- new_lot = self._create_lot(self.product_a)
- self._create_package_in_location(
- self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)]
- )
- self._create_package_in_location(
- self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)]
- )
- self._test_change_pack_lot(
- line,
- new_lot.name,
- success=False,
- message=self.service.msg_store.several_packs_in_location(self.shelf1),
- )
-
- def test_change_pack_lot_change_lot_from_package_error(self):
- # we shouldn't be allowed to replace a package by a lot
- # if the lot is not a package in the quants (because we
- # could then replace a package by a single unit)
- self.product_a.tracking = "lot"
- initial_lot = self._create_lot(self.product_a)
- self._create_package_in_location(
- self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)]
- )
- self._simulate_batch_selected(self.batch, fill_stock=False)
- line = self.batch.picking_ids.move_line_ids
- # create a lot and put a unit in the location without package
- new_lot = self._create_lot(self.product_a)
- self._update_qty_in_location(self.shelf1, line.product_id, 1, lot=new_lot)
- self._test_change_pack_lot(
- line,
- new_lot.name,
- success=False,
- message=self.service.msg_store.lot_is_not_a_package(new_lot),
- )
def test_change_pack_lot_change_lot_ok(self):
initial_lot = self._create_lot(self.product_a)
@@ -282,226 +89,17 @@ def test_change_pack_lot_change_lot_ok(self):
success=True,
message=self.service.msg_store.lot_replaced_by_lot(initial_lot, new_lot),
)
-
self.assertRecordValues(line, [{"lot_id": new_lot.id}])
- # check that reservations have been updated
- self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot)
- self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot)
- def test_change_pack_lot_change_lot_different_location_ok(self):
- self.product_a.tracking = "lot"
+ def test_change_pack_lot_change_error(self):
initial_lot = self._create_lot(self.product_a)
self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot)
self._simulate_batch_selected(self.batch, fill_stock=False)
line = self.batch.picking_ids.move_line_ids
- new_lot = self._create_lot(self.product_a)
- # ensure we have our new package in a different location
- self._update_qty_in_location(self.shelf2, line.product_id, 10, lot=new_lot)
- message = self.service.msg_store.lot_replaced_by_lot(initial_lot, new_lot)
- message["body"] += " A draft inventory has been created for control."
- self._test_change_pack_lot(
- line, new_lot.name, success=True, message=message,
- )
-
- self.assertRecordValues(line, [{"lot_id": new_lot.id}])
- # check that reservations have been updated
- self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot)
- self.assert_quant_reserved_qty(line, lambda: line.product_qty, lot=new_lot)
- self.assert_control_stock_inventory(self.shelf1, line.product_id, new_lot)
-
-
-class ClusterPickingChangePackLotCaseSpecial(ClusterPickingChangePackLotCommon):
- """Tests covering the /change_pack_lot endpoint
-
- Special cases where we use a custom batch transfer
- """
-
- def _create_picking_with_package_level(self, packages):
- picking_form = Form(self.env["stock.picking"])
- picking_form.partner_id = self.customer
- picking_form.origin = "test"
- picking_form.picking_type_id = self.picking_type
- picking_form.location_id = self.stock_location
- picking_form.location_dest_id = self.packing_location
- for package in packages:
- with picking_form.package_level_ids_details.new() as move:
- move.package_id = package
- picking = picking_form.save()
- picking.action_confirm()
- picking.action_assign()
- return picking
-
- def _create_batch_with_pickings(self, pickings):
- batch_form = Form(self.env["stock.picking.batch"])
- for picking in pickings:
- batch_form.picking_ids.add(picking)
- batch = batch_form.save()
- return batch
-
- def test_change_pack_lot_change_pack_different_content_error(self):
- # create the initial package, that will be reserved first
- initial_package = self._create_package_in_location(
- self.shelf1,
- [
- self.PackageContent(self.product_a, 10, lot=None),
- self.PackageContent(self.product_b, 10, lot=None),
- ],
- )
- picking = self._create_picking_with_package_level(initial_package)
- batch = self._create_batch_with_pickings(picking)
- self._simulate_batch_selected(batch, fill_stock=False)
-
- # create a new package in the same location
- # with a different content
- new_package = self._create_package_in_location(
- self.shelf1,
- [
- self.PackageContent(self.product_a, 10, lot=None),
- self.PackageContent(self.product_b, 8, lot=None),
- ],
- )
-
- lines = batch.picking_ids.move_line_ids
- # try to use the new package, which has a different content,
- # not accepted
- self._test_change_pack_lot(
- lines[0],
- new_package.name,
- success=False,
- message=self.service.msg_store.package_different_content(new_package),
- )
-
- def test_change_pack_lot_change_pack_multi_content_with_lot(self):
- (self.product_a + self.product_b).tracking = "lot"
- # create a package with 2 products tracked by lot, stored in shelf1
- # this package is reserved first on the move line
- initial_lot_a = self._create_lot(self.product_a)
- initial_lot_b = self._create_lot(self.product_b)
- initial_package = self._create_package_in_location(
- self.shelf1,
- [
- self.PackageContent(self.product_a, 10, initial_lot_a),
- self.PackageContent(self.product_b, 10, initial_lot_b),
- ],
- )
-
- # create and reserve our transfer using the initial package
- picking = self._create_picking_with_package_level(initial_package)
- batch = self._create_batch_with_pickings(picking)
- self._simulate_batch_selected(batch, fill_stock=False)
-
- lines = picking.move_line_ids
- package_level = lines.mapped("package_level_id")
-
- # create a second package with the same content, which will be used
- # as replacement
- new_lot_a = self._create_lot(self.product_a)
- new_lot_b = self._create_lot(self.product_b)
- new_package = self._create_package_in_location(
- self.shelf1,
- [
- self.PackageContent(self.product_a, 10, new_lot_a),
- self.PackageContent(self.product_b, 10, new_lot_b),
- ],
- )
- # changing the package of the first line will change all of them
- self._test_change_pack_lot(
- lines[0],
- new_package.name,
- success=True,
- message=self.service.msg_store.package_replaced_by_package(
- initial_package, new_package
- ),
- )
-
- self.assertRecordValues(
- lines,
- [
- {
- "package_id": new_package.id,
- "result_package_id": new_package.id,
- "lot_id": new_lot_a.id,
- },
- {
- "package_id": new_package.id,
- "result_package_id": new_package.id,
- "lot_id": new_lot_b.id,
- },
- ],
- )
-
- self.assertRecordValues(package_level, [{"package_id": new_package.id}])
- # check that reservations have been updated
- for line in lines:
- self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package)
- self.assert_quant_reserved_qty(
- line, lambda: line.product_qty, package=new_package
- )
-
- def test_change_pack_lot_change_pack_steal_from_other_move_line(self):
- # create 2 picking, each with its own package
- package1 = self._create_package_in_location(
- self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)],
- )
- picking1 = self._create_picking_with_package_level(package1)
- self.assertEqual(picking1.move_line_ids.package_id, package1)
-
- package2 = self._create_package_in_location(
- self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)],
- )
- picking2 = self._create_picking_with_package_level(package2)
- self.assertEqual(picking2.move_line_ids.package_id, package2)
-
- batch = self._create_batch_with_pickings(picking1 + picking2)
- self._simulate_batch_selected(batch, fill_stock=False)
-
- line = picking1.move_line_ids
- # We "steal" package2 for the picking1
+ # ensure we have our new package in the same location
self._test_change_pack_lot(
line,
- package2.name,
- success=True,
- message=self.service.msg_store.package_replaced_by_package(
- package1, package2
- ),
- )
-
- self.assertRecordValues(
- picking1.move_line_ids,
- [
- {
- "package_id": package2.id,
- "result_package_id": package2.id,
- "state": "assigned",
- }
- ],
- )
- self.assertRecordValues(
- picking2.move_line_ids,
- [
- {
- "package_id": package1.id,
- "result_package_id": package1.id,
- "state": "assigned",
- }
- ],
- )
- self.assertRecordValues(
- picking1.package_level_ids,
- [{"package_id": package2.id, "state": "assigned"}],
- )
- self.assertRecordValues(
- picking2.package_level_ids,
- [{"package_id": package1.id, "state": "assigned"}],
- )
- # check that reservations have been updated
- self.assert_quant_reserved_qty(
- picking1.move_line_ids,
- lambda: picking1.move_line_ids.product_qty,
- package=package2,
- )
- self.assert_quant_reserved_qty(
- picking2.move_line_ids,
- lambda: picking2.move_line_ids.product_qty,
- package=package1,
+ "NOT_FOUND",
+ success=False,
+ message=self.service.msg_store.no_package_or_lot_for_barcode("NOT_FOUND"),
)
diff --git a/shopfloor/tests/test_zone_picking_change_pack_lot.py b/shopfloor/tests/test_zone_picking_change_pack_lot.py
index 06f6047342..8818f68391 100644
--- a/shopfloor/tests/test_zone_picking_change_pack_lot.py
+++ b/shopfloor/tests/test_zone_picking_change_pack_lot.py
@@ -6,6 +6,8 @@ class ZonePickingChangePackLotCase(ZonePickingCommonCase):
* /change_pack_lot
+ Only simple cases are tested to check the flow of responses on success and
+ error, the "change.package.lot" component is tested in its own tests.
"""
def test_change_pack_lot_wrong_parameters(self):
@@ -190,69 +192,3 @@ def test_change_pack_lot_change_lot_ok(self):
previous_lot, self.free_lot
),
)
-
- def test_change_pack_lot_change_lot_ok_with_control_stock(self):
- zone_location = self.zone_location
- picking_type = self.picking2.picking_type_id
- move_line = self.picking2.move_line_ids[0]
- previous_lot = move_line.lot_id
- self.free_lot.product_id = move_line.product_id
- # ensure we have our new lot but in another location
- self._update_qty_in_location(
- self.zone_sublocation1,
- move_line.product_id,
- move_line.product_uom_qty,
- lot=self.free_lot,
- )
- # change lot
- response = self.service.dispatch(
- "change_pack_lot",
- params={
- "zone_location_id": zone_location.id,
- "picking_type_id": picking_type.id,
- "move_line_id": move_line.id,
- "barcode": self.free_lot.name,
- },
- )
- # check data
- self.assertRecordValues(move_line, [{"lot_id": self.free_lot.id}])
- # check that reservations could not be made as the lot is
- # theoretically elsewhere
- previous_quant = self.env["stock.quant"].search(
- [
- ("location_id", "=", move_line.location_id.id),
- ("product_id", "=", move_line.product_id.id),
- ("lot_id", "=", previous_lot.id),
- ]
- )
- self.assertEqual(previous_quant.quantity, 10)
- self.assertEqual(previous_quant.reserved_quantity, 0)
- new_quant = self.env["stock.quant"].search(
- [
- ("location_id", "=", move_line.location_id.id),
- ("product_id", "=", move_line.product_id.id),
- ("lot_id", "=", self.free_lot.id),
- ]
- )
- self.assertFalse(new_quant)
- # as such an inventory of control has been generated to check this issue
- control_inventory_name = "Pick: stock issue on lot: {} found in {}".format(
- self.free_lot.name, move_line.location_id.name
- )
- control_inventory = self.env["stock.inventory"].search(
- [
- ("name", "=", control_inventory_name),
- ("location_ids", "in", move_line.location_id.id),
- ("product_ids", "in", move_line.product_id.id),
- ("state", "in", ("draft", "confirm")),
- ]
- )
- self.assertTrue(control_inventory)
- # check response
- message = self.service.msg_store.lot_replaced_by_lot(
- previous_lot, self.free_lot
- )
- message["body"] += " A draft inventory has been created for control."
- self.assert_response_set_line_destination(
- response, zone_location, picking_type, move_line, message=message,
- )
From 4ecf94e2d074fbb302d9412dc6bd4c0aa20e7411 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 9 Oct 2020 15:58:15 +0200
Subject: [PATCH 387/940] shopfloor: add readme
---
shopfloor/readme/CONFIGURE.rst | 23 ++++++++++++++++++++++-
shopfloor/readme/CONTRIBUTORS.rst | 12 ++++++++++--
shopfloor/readme/CREDITS.rst | 5 +++++
shopfloor/readme/DESCRIPTION.rst | 15 ++++++++++++++-
shopfloor/readme/ROADMAP.rst | 1 -
5 files changed, 51 insertions(+), 5 deletions(-)
create mode 100644 shopfloor/readme/CREDITS.rst
diff --git a/shopfloor/readme/CONFIGURE.rst b/shopfloor/readme/CONFIGURE.rst
index fe0cc8cdee..059a7dda31 100644
--- a/shopfloor/readme/CONFIGURE.rst
+++ b/shopfloor/readme/CONFIGURE.rst
@@ -1,4 +1,25 @@
-writeme
+Profiles
+--------
+
+In Inventory / Configuration / Shopfloor / Profiles.
+
+The profiles are used to restrict which menus are shown on the frontend
+application. When a user logs in the scanner application, they have to
+select their profile, so the correct menus are shown.
+
+Menus
+-----
+
+In Inventory / Configuration / Shopfloor / Menus.
+
+The menus are displayed on the frontend application and store the configuration
+of the scenarios. Each menu must use a scenario and defines which Operation Types
+they are allowed to process.
+
+Their profile will restrict the visibility to the profile chosen on the device.
+If a menu has no profile, it is shown in every profile.
+
+Some scenarios may have additional options, which are explained in tooltips.
Logs retention
--------------
diff --git a/shopfloor/readme/CONTRIBUTORS.rst b/shopfloor/readme/CONTRIBUTORS.rst
index 6fc648282a..4d4d24ed1b 100644
--- a/shopfloor/readme/CONTRIBUTORS.rst
+++ b/shopfloor/readme/CONTRIBUTORS.rst
@@ -1,4 +1,12 @@
-* Alexandre Fayolle
* Guewen Baconnier
+* Simone Orsi
+* Sébastien Alix
+* Alexandre Fayolle
+* Benoit Guillot
+* Thierry Ducrest
+
+Design
+~~~~~~
- ADD YOURSELF
+* Joël Grand-Guillaume
+* Jacques-Etienne Baudoux
diff --git a/shopfloor/readme/CREDITS.rst b/shopfloor/readme/CREDITS.rst
new file mode 100644
index 0000000000..dd789bcc35
--- /dev/null
+++ b/shopfloor/readme/CREDITS.rst
@@ -0,0 +1,5 @@
+**Financial support**
+
+* Cosanum
+* Camptocamp R&D
+* Akretion R&D
diff --git a/shopfloor/readme/DESCRIPTION.rst b/shopfloor/readme/DESCRIPTION.rst
index 756a0db67d..6242bc91fc 100644
--- a/shopfloor/readme/DESCRIPTION.rst
+++ b/shopfloor/readme/DESCRIPTION.rst
@@ -1 +1,14 @@
-write me
+Shopfloor is a barcode scanner application for internal warehouse operations.
+
+The application supports scenarios, to relate to Operation Types:
+
+* Cluster Picking
+* Zone Picking
+* Checkout/Packing
+* Delivery
+* Location Content Transfer
+* Single Pack Transfer
+
+This module provides REST APIs to support the scenarios. It needs a frontend
+to consume the backend APIs and provide screens for users on barcode devices.
+A default front-end application is provided by ``shopfloor_mobile``.
diff --git a/shopfloor/readme/ROADMAP.rst b/shopfloor/readme/ROADMAP.rst
index 756a0db67d..e69de29bb2 100644
--- a/shopfloor/readme/ROADMAP.rst
+++ b/shopfloor/readme/ROADMAP.rst
@@ -1 +0,0 @@
-write me
From 06ad3d5e4c16286453e5c45216f706a2705ecf2e Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 9 Oct 2020 16:24:17 +0200
Subject: [PATCH 388/940] shopfloor: add copyrights
---
shopfloor/__manifest__.py | 4 +++-
shopfloor/actions/base_action.py | 2 ++
shopfloor/actions/change_package_lot.py | 2 ++
shopfloor/actions/completion_info.py | 2 ++
shopfloor/actions/data.py | 2 ++
shopfloor/actions/data_detail.py | 2 ++
shopfloor/actions/inventory.py | 2 ++
shopfloor/actions/location_content_transfer_sorter.py | 2 ++
shopfloor/actions/message.py | 2 ++
shopfloor/actions/savepoint.py | 2 ++
shopfloor/actions/search.py | 2 ++
shopfloor/controllers/main.py | 3 +++
shopfloor/migrations/13.0.1.1.0/pre-migration.py | 2 ++
shopfloor/models/priority_postpone_mixin.py | 2 ++
shopfloor/models/res_partner.py | 2 ++
shopfloor/models/shopfloor_log.py | 2 ++
shopfloor/models/shopfloor_menu.py | 2 ++
shopfloor/models/shopfloor_profile.py | 2 ++
shopfloor/models/stock_inventory.py | 2 ++
shopfloor/models/stock_location.py | 2 ++
shopfloor/models/stock_move.py | 2 ++
shopfloor/models/stock_move_line.py | 2 ++
shopfloor/models/stock_package_level.py | 2 ++
shopfloor/models/stock_picking.py | 2 ++
shopfloor/models/stock_picking_batch.py | 2 ++
shopfloor/models/stock_picking_type.py | 2 ++
shopfloor/models/stock_quant.py | 2 ++
shopfloor/models/stock_quant_package.py | 2 ++
shopfloor/services/app.py | 2 ++
shopfloor/services/checkout.py | 2 ++
shopfloor/services/cluster_picking.py | 2 ++
shopfloor/services/delivery.py | 2 ++
shopfloor/services/location_content_transfer.py | 2 ++
shopfloor/services/menu.py | 2 ++
shopfloor/services/picking_batch.py | 2 ++
shopfloor/services/profile.py | 2 ++
shopfloor/services/scan_anything.py | 2 ++
shopfloor/services/schema.py | 2 ++
shopfloor/services/schema_detail.py | 2 ++
shopfloor/services/service.py | 3 +++
shopfloor/services/single_pack_transfer.py | 3 +++
shopfloor/services/validator.py | 2 ++
shopfloor/services/zone_picking.py | 2 ++
shopfloor/tests/common.py | 2 ++
shopfloor/tests/test_actions_change_package_lot.py | 2 ++
shopfloor/tests/test_actions_data.py | 2 ++
shopfloor/tests/test_actions_data_detail.py | 2 ++
shopfloor/tests/test_app.py | 2 ++
shopfloor/tests/test_checkout_base.py | 2 ++
shopfloor/tests/test_checkout_cancel_line.py | 2 ++
shopfloor/tests/test_checkout_change_packaging.py | 2 ++
shopfloor/tests/test_checkout_done.py | 2 ++
shopfloor/tests/test_checkout_list_package.py | 2 ++
shopfloor/tests/test_checkout_new_package.py | 2 ++
shopfloor/tests/test_checkout_no_package.py | 2 ++
shopfloor/tests/test_checkout_scan.py | 2 ++
shopfloor/tests/test_checkout_scan_line.py | 2 ++
shopfloor/tests/test_checkout_scan_package_action.py | 2 ++
shopfloor/tests/test_checkout_select.py | 2 ++
shopfloor/tests/test_checkout_select_line.py | 2 ++
shopfloor/tests/test_checkout_select_package_base.py | 4 ++++
shopfloor/tests/test_checkout_set_qty.py | 3 +++
shopfloor/tests/test_checkout_summary.py | 3 +++
shopfloor/tests/test_cluster_picking_base.py | 3 +++
shopfloor/tests/test_cluster_picking_batch.py | 3 +++
shopfloor/tests/test_cluster_picking_change_pack_lot.py | 3 +++
shopfloor/tests/test_cluster_picking_scan.py | 3 +++
shopfloor/tests/test_cluster_picking_select.py | 3 +++
shopfloor/tests/test_cluster_picking_skip.py | 3 +++
shopfloor/tests/test_cluster_picking_stock_issue.py | 3 +++
shopfloor/tests/test_cluster_picking_unload.py | 3 +++
shopfloor/tests/test_delivery_base.py | 3 +++
shopfloor/tests/test_delivery_scan_deliver.py | 3 +++
shopfloor/tests/test_location_content_transfer_base.py | 3 +++
.../test_location_content_transfer_set_destination_all.py | 3 +++
shopfloor/tests/test_location_content_transfer_single.py | 3 +++
shopfloor/tests/test_location_content_transfer_start.py | 3 +++
shopfloor/tests/test_menu.py | 3 +++
shopfloor/tests/test_misc.py | 3 +++
shopfloor/tests/test_move_action_assign.py | 3 +++
shopfloor/tests/test_openapi.py | 3 +++
shopfloor/tests/test_profile.py | 3 +++
shopfloor/tests/test_scan_anything.py | 3 +++
shopfloor/tests/test_single_pack_transfer.py | 4 ++++
shopfloor/tests/test_single_pack_transfer_base.py | 3 +++
shopfloor/tests/test_stock_split.py | 2 ++
shopfloor/tests/test_zone_picking_base.py | 2 ++
shopfloor/tests/test_zone_picking_change_pack_lot.py | 2 ++
shopfloor/tests/test_zone_picking_select_line.py | 2 ++
shopfloor/tests/test_zone_picking_select_picking_type.py | 2 ++
shopfloor/tests/test_zone_picking_set_line_destination.py | 2 ++
shopfloor/tests/test_zone_picking_start.py | 2 ++
shopfloor/tests/test_zone_picking_stock_issue.py | 2 ++
shopfloor/tests/test_zone_picking_unload_all.py | 2 ++
shopfloor/tests/test_zone_picking_unload_set_destination.py | 2 ++
shopfloor/tests/test_zone_picking_unload_single.py | 2 ++
shopfloor/tests/test_zone_picking_zero_check.py | 2 ++
97 files changed, 225 insertions(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index b1d12b54fb..899cf0321b 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -1,4 +1,6 @@
-# © 2020 Camptocamp, Akretion, BCIM
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# Copyright 2020 Akretion (http://www.akretion.com)
+# Copyright 2020 BCIM
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py
index b094c2c812..fb7eb2ae12 100644
--- a/shopfloor/actions/base_action.py
+++ b/shopfloor/actions/base_action.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.addons.component.core import AbstractComponent
diff --git a/shopfloor/actions/change_package_lot.py b/shopfloor/actions/change_package_lot.py
index 2537cea5b5..c21e8e92e8 100644
--- a/shopfloor/actions/change_package_lot.py
+++ b/shopfloor/actions/change_package_lot.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, exceptions
from odoo.tools.float_utils import float_compare, float_is_zero
diff --git a/shopfloor/actions/completion_info.py b/shopfloor/actions/completion_info.py
index 16cab4a0cf..a8fec75f2e 100644
--- a/shopfloor/actions/completion_info.py
+++ b/shopfloor/actions/completion_info.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _
from odoo.addons.component.core import Component
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 5603890e76..5301f90245 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import fields
from odoo.addons.component.core import Component
diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py
index 4e25abbe42..2bef510cbf 100644
--- a/shopfloor/actions/data_detail.py
+++ b/shopfloor/actions/data_detail.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tools.float_utils import float_round
from odoo.addons.component.core import Component
diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py
index a46bee4bd8..f3f54f9770 100644
--- a/shopfloor/actions/inventory.py
+++ b/shopfloor/actions/inventory.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _
from odoo.addons.component.core import Component
diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py
index 49b45430dc..1ad4aa1f8b 100644
--- a/shopfloor/actions/location_content_transfer_sorter.py
+++ b/shopfloor/actions/location_content_transfer_sorter.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.addons.component.core import Component
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 1b5cc7f559..e600f00c4e 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _
from odoo.addons.component.core import Component
diff --git a/shopfloor/actions/savepoint.py b/shopfloor/actions/savepoint.py
index 9e031de542..96daa8fe61 100644
--- a/shopfloor/actions/savepoint.py
+++ b/shopfloor/actions/savepoint.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import uuid
from psycopg2 import sql
diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py
index a757257583..922df08040 100644
--- a/shopfloor/actions/search.py
+++ b/shopfloor/actions/search.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.addons.component.core import Component
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index 3f04afa240..86676e235d 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# Copyright 2020 Akretion (http://www.akretion.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from werkzeug.exceptions import BadRequest
from odoo.http import request
diff --git a/shopfloor/migrations/13.0.1.1.0/pre-migration.py b/shopfloor/migrations/13.0.1.1.0/pre-migration.py
index 8255f2c535..ef555c78c0 100644
--- a/shopfloor/migrations/13.0.1.1.0/pre-migration.py
+++ b/shopfloor/migrations/13.0.1.1.0/pre-migration.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from psycopg2 import sql
from odoo.tools import column_exists
diff --git a/shopfloor/models/priority_postpone_mixin.py b/shopfloor/models/priority_postpone_mixin.py
index 41fa877961..80832f6d7e 100644
--- a/shopfloor/models/priority_postpone_mixin.py
+++ b/shopfloor/models/priority_postpone_mixin.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import api, fields, models
diff --git a/shopfloor/models/res_partner.py b/shopfloor/models/res_partner.py
index b9a8986af0..35111600bb 100644
--- a/shopfloor/models/res_partner.py
+++ b/shopfloor/models/res_partner.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import fields, models
diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py
index a17e9a1432..9a0401cedd 100644
--- a/shopfloor/models/shopfloor_log.py
+++ b/shopfloor/models/shopfloor_log.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import logging
from datetime import datetime, timedelta
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index 0a16a91ab5..9429a8c142 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, api, exceptions, fields, models
diff --git a/shopfloor/models/shopfloor_profile.py b/shopfloor/models/shopfloor_profile.py
index eb6b6e1309..2d490b3b3f 100644
--- a/shopfloor/models/shopfloor_profile.py
+++ b/shopfloor/models/shopfloor_profile.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import api, fields, models
diff --git a/shopfloor/models/stock_inventory.py b/shopfloor/models/stock_inventory.py
index 3581a18dce..2e23d7b324 100644
--- a/shopfloor/models/stock_inventory.py
+++ b/shopfloor/models/stock_inventory.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import models
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
index 9641cec1a0..bb4f74f099 100644
--- a/shopfloor/models/stock_location.py
+++ b/shopfloor/models/stock_location.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import fields, models
from odoo.tools.float_utils import float_compare
diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py
index f1690dd182..2794cf4449 100644
--- a/shopfloor/models/stock_move.py
+++ b/shopfloor/models/stock_move.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, models
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index b9146dc8aa..bd8b2c3de3 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, exceptions, fields, models
from odoo.exceptions import UserError
from odoo.tools.float_utils import float_compare
diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py
index 84822602e4..8dd4aea84a 100644
--- a/shopfloor/models/stock_package_level.py
+++ b/shopfloor/models/stock_package_level.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import models
diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py
index f42bed1277..35cb047cc9 100644
--- a/shopfloor/models/stock_picking.py
+++ b/shopfloor/models/stock_picking.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import api, fields, models
diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py
index 07bf57a099..f16aa11da1 100644
--- a/shopfloor/models/stock_picking_batch.py
+++ b/shopfloor/models/stock_picking_batch.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import api, fields, models
diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py
index 600fa3e58b..2525db4aaa 100644
--- a/shopfloor/models/stock_picking_type.py
+++ b/shopfloor/models/stock_picking_type.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import api, fields, models
diff --git a/shopfloor/models/stock_quant.py b/shopfloor/models/stock_quant.py
index 77c6667359..d20af1278e 100644
--- a/shopfloor/models/stock_quant.py
+++ b/shopfloor/models/stock_quant.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import models
diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py
index f45e08e920..8f7ee23462 100644
--- a/shopfloor/models/stock_quant_package.py
+++ b/shopfloor/models/stock_quant_package.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, api, exceptions, fields, models
diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py
index d9dcab6ef9..d0d2be4704 100644
--- a/shopfloor/services/app.py
+++ b/shopfloor/services/app.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.addons.component.core import Component
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index a4753fb0de..88ff30d3c9 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _
from odoo.addons.base_rest.components.service import to_int
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 5cfc44e760..ed3a1b77e7 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, fields
from odoo.osv import expression
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index debbea4126..74cf09bce6 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, fields
from odoo.osv import expression
from odoo.tools.float_utils import float_is_zero
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 9a92d0c28f..4e296d82a8 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _
from odoo.addons.base_rest.components.service import to_int
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 3d9bad9d98..3fce4f2182 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.osv import expression
from odoo.addons.base_rest.components.service import to_int
diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py
index 44de8eb7e8..836efe4cbd 100644
--- a/shopfloor/services/picking_batch.py
+++ b/shopfloor/services/picking_batch.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.osv import expression
from odoo.addons.component.core import Component
diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py
index af9b9d2841..55d2297937 100644
--- a/shopfloor/services/profile.py
+++ b/shopfloor/services/profile.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
diff --git a/shopfloor/services/scan_anything.py b/shopfloor/services/scan_anything.py
index de8b24dc1f..a943fc5c65 100644
--- a/shopfloor/services/scan_anything.py
+++ b/shopfloor/services/scan_anything.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _
from odoo.addons.component.core import Component
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index 4d8742930b..bb9071e9d7 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.addons.component.core import Component
diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py
index 077df97344..f57d40e33f 100644
--- a/shopfloor/services/schema_detail.py
+++ b/shopfloor/services/schema_detail.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.addons.component.core import Component
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 9cc6c7a8d4..6bce31321b 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# Copyright 2020 Akretion (http://www.akretion.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import traceback
from werkzeug.urls import url_encode, url_join
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 0af66ebb2d..e264be477a 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# Copyright 2020 Akretion (http://www.akretion.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import fields
from odoo.addons.base_rest.components.service import to_int
diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py
index 21c46847de..b26d011375 100644
--- a/shopfloor/services/validator.py
+++ b/shopfloor/services/validator.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.addons.component.core import AbstractComponent
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 602e387cfc..8201eaec44 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import functools
from odoo.fields import first
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 383a9d1068..88bddd0ba2 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from collections import namedtuple
from contextlib import contextmanager
from pprint import pformat
diff --git a/shopfloor/tests/test_actions_change_package_lot.py b/shopfloor/tests/test_actions_change_package_lot.py
index 4f8f4c0852..967729f044 100644
--- a/shopfloor/tests/test_actions_change_package_lot.py
+++ b/shopfloor/tests/test_actions_change_package_lot.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tests.common import Form
from .common import CommonCase
diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py
index 43bff8b1e3..6215ea5e8e 100644
--- a/shopfloor/tests/test_actions_data.py
+++ b/shopfloor/tests/test_actions_data.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import logging
from .common import CommonCase, PickingBatchMixin
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index 22821ee0a5..1595c0af85 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import base64
import io
diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py
index d45965861e..11088781ad 100644
--- a/shopfloor/tests/test_app.py
+++ b/shopfloor/tests/test_app.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .common import CommonCase
diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py
index 25cbdc1dc5..4c6ffe65c7 100644
--- a/shopfloor/tests/test_checkout_base.py
+++ b/shopfloor/tests/test_checkout_base.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .common import CommonCase
diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py
index 108df0e03c..a18adba265 100644
--- a/shopfloor/tests/test_checkout_cancel_line.py
+++ b/shopfloor/tests/test_checkout_cancel_line.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_checkout_base import CheckoutCommonCase
diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py
index f9488e6714..b3bcf36aa6 100644
--- a/shopfloor/tests/test_checkout_change_packaging.py
+++ b/shopfloor/tests/test_checkout_change_packaging.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_checkout_base import CheckoutCommonCase
diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py
index c5ff9e9240..2b675ed565 100644
--- a/shopfloor/tests/test_checkout_done.py
+++ b/shopfloor/tests/test_checkout_done.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_checkout_base import CheckoutCommonCase
diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py
index fe81fbb366..e32fcf16d6 100644
--- a/shopfloor/tests/test_checkout_list_package.py
+++ b/shopfloor/tests/test_checkout_list_package.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_checkout_base import CheckoutCommonCase
from .test_checkout_select_package_base import CheckoutSelectPackageMixin
diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py
index 7e62c0f3f0..a6a038f72f 100644
--- a/shopfloor/tests/test_checkout_new_package.py
+++ b/shopfloor/tests/test_checkout_new_package.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_checkout_base import CheckoutCommonCase
from .test_checkout_select_package_base import CheckoutSelectPackageMixin
diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py
index f997ccad18..7242077825 100644
--- a/shopfloor/tests/test_checkout_no_package.py
+++ b/shopfloor/tests/test_checkout_no_package.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_checkout_base import CheckoutCommonCase
from .test_checkout_select_package_base import CheckoutSelectPackageMixin
diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py
index 672bfec403..1af8473da2 100644
--- a/shopfloor/tests/test_checkout_scan.py
+++ b/shopfloor/tests/test_checkout_scan.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_checkout_base import CheckoutCommonCase
diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py
index 94b4b5d890..bedc37a8bf 100644
--- a/shopfloor/tests/test_checkout_scan_line.py
+++ b/shopfloor/tests/test_checkout_scan_line.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_checkout_base import CheckoutCommonCase
from .test_checkout_select_package_base import CheckoutSelectPackageMixin
diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py
index 26ab339947..948e335840 100644
--- a/shopfloor/tests/test_checkout_scan_package_action.py
+++ b/shopfloor/tests/test_checkout_scan_package_action.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from itertools import product
from .test_checkout_base import CheckoutCommonCase
diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py
index 3458b76e06..60968810b1 100644
--- a/shopfloor/tests/test_checkout_select.py
+++ b/shopfloor/tests/test_checkout_select.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_checkout_base import CheckoutCommonCase
diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py
index c84d5bed9d..477fed1f3f 100644
--- a/shopfloor/tests/test_checkout_select_line.py
+++ b/shopfloor/tests/test_checkout_select_line.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_checkout_base import CheckoutCommonCase
from .test_checkout_select_package_base import CheckoutSelectPackageMixin
diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py
index 845c6d8df0..151beb76e2 100644
--- a/shopfloor/tests/test_checkout_select_package_base.py
+++ b/shopfloor/tests/test_checkout_select_package_base.py
@@ -1,3 +1,7 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+
class CheckoutSelectPackageMixin:
def _assert_selected_response(
self, response, selected_lines, message=None, packing_info=False
diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py
index 99229c7e0d..b49b1aed5e 100644
--- a/shopfloor/tests/test_checkout_set_qty.py
+++ b/shopfloor/tests/test_checkout_set_qty.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_checkout_base import CheckoutCommonCase
from .test_checkout_select_package_base import CheckoutSelectPackageMixin
diff --git a/shopfloor/tests/test_checkout_summary.py b/shopfloor/tests/test_checkout_summary.py
index b7d71cc271..6fd3636f26 100644
--- a/shopfloor/tests/test_checkout_summary.py
+++ b/shopfloor/tests/test_checkout_summary.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_checkout_base import CheckoutCommonCase
diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py
index 2fd44ecb46..093322dc1f 100644
--- a/shopfloor/tests/test_cluster_picking_base.py
+++ b/shopfloor/tests/test_cluster_picking_base.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .common import CommonCase, PickingBatchMixin
diff --git a/shopfloor/tests/test_cluster_picking_batch.py b/shopfloor/tests/test_cluster_picking_batch.py
index b7a2e915fc..fc95b63af7 100644
--- a/shopfloor/tests/test_cluster_picking_batch.py
+++ b/shopfloor/tests/test_cluster_picking_batch.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .common import CommonCase, PickingBatchMixin
diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
index 190b8f9c41..966816fbaf 100644
--- a/shopfloor/tests/test_cluster_picking_change_pack_lot.py
+++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_cluster_picking_base import ClusterPickingCommonCase
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 5f550f7205..000aeb6b99 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_cluster_picking_base import (
ClusterPickingCommonCase,
ClusterPickingLineCommonCase,
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index 1b86cdf690..48c6d9f60e 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_cluster_picking_base import ClusterPickingCommonCase
diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py
index a0054f741e..bb28881c70 100644
--- a/shopfloor/tests/test_cluster_picking_skip.py
+++ b/shopfloor/tests/test_cluster_picking_skip.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_cluster_picking_base import ClusterPickingCommonCase
diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py
index 130319cea7..eb878075f5 100644
--- a/shopfloor/tests/test_cluster_picking_stock_issue.py
+++ b/shopfloor/tests/test_cluster_picking_stock_issue.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_cluster_picking_base import ClusterPickingCommonCase
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 1df4f0e293..6616b1cb52 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_cluster_picking_base import ClusterPickingCommonCase
diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py
index 4fd5097dc9..a17f2f37fe 100644
--- a/shopfloor/tests/test_delivery_base.py
+++ b/shopfloor/tests/test_delivery_base.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .common import CommonCase
diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py
index 97524254bf..c1aa38a00a 100644
--- a/shopfloor/tests/test_delivery_scan_deliver.py
+++ b/shopfloor/tests/test_delivery_scan_deliver.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_delivery_base import DeliveryCommonCase
diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py
index 40e24385c5..f4076b1bfe 100644
--- a/shopfloor/tests/test_location_content_transfer_base.py
+++ b/shopfloor/tests/test_location_content_transfer_base.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .common import CommonCase
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py
index 93cd3d80bc..5edc6ad50b 100644
--- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_location_content_transfer_base import LocationContentTransferCommonCase
diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py
index cdbf6efc49..74f4ecc1f5 100644
--- a/shopfloor/tests/test_location_content_transfer_single.py
+++ b/shopfloor/tests/test_location_content_transfer_single.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_location_content_transfer_base import LocationContentTransferCommonCase
diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py
index 62c4a6f694..20c3e7da07 100644
--- a/shopfloor/tests/test_location_content_transfer_start.py
+++ b/shopfloor/tests/test_location_content_transfer_start.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_location_content_transfer_base import LocationContentTransferCommonCase
diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py
index 31ef016042..5ec40e40d7 100644
--- a/shopfloor/tests/test_menu.py
+++ b/shopfloor/tests/test_menu.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .common import CommonCase
diff --git a/shopfloor/tests/test_misc.py b/shopfloor/tests/test_misc.py
index 3e29f16b8b..9955e75393 100644
--- a/shopfloor/tests/test_misc.py
+++ b/shopfloor/tests/test_misc.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from odoo import exceptions
from odoo.tests.common import SavepointCase
diff --git a/shopfloor/tests/test_move_action_assign.py b/shopfloor/tests/test_move_action_assign.py
index 7f387f7d6b..5b8b386ba1 100644
--- a/shopfloor/tests/test_move_action_assign.py
+++ b/shopfloor/tests/test_move_action_assign.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .common import CommonCase
diff --git a/shopfloor/tests/test_openapi.py b/shopfloor/tests/test_openapi.py
index 913324c4a5..561b040072 100644
--- a/shopfloor/tests/test_openapi.py
+++ b/shopfloor/tests/test_openapi.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .common import CommonCase
diff --git a/shopfloor/tests/test_profile.py b/shopfloor/tests/test_profile.py
index a3eb4489a5..52189578f7 100644
--- a/shopfloor/tests/test_profile.py
+++ b/shopfloor/tests/test_profile.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .common import CommonCase
diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py
index 87128304e0..e1d20ac132 100644
--- a/shopfloor/tests/test_scan_anything.py
+++ b/shopfloor/tests/test_scan_anything.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from .test_actions_data_detail import ActionsDataDetailCaseBase
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index 9057f51755..e3d5916709 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -1,3 +1,7 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# Copyright 2020 Akretion (http://www.akretion.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
from odoo.tests.common import Form
from .test_single_pack_transfer_base import SinglePackTransferCommonBase
diff --git a/shopfloor/tests/test_single_pack_transfer_base.py b/shopfloor/tests/test_single_pack_transfer_base.py
index 8f04301fc9..d512d10bcc 100644
--- a/shopfloor/tests/test_single_pack_transfer_base.py
+++ b/shopfloor/tests/test_single_pack_transfer_base.py
@@ -1,3 +1,6 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# Copyright 2020 Akretion (http://www.akretion.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .common import CommonCase
diff --git a/shopfloor/tests/test_stock_split.py b/shopfloor/tests/test_stock_split.py
index a0554d6ea1..af2c925d5c 100644
--- a/shopfloor/tests/test_stock_split.py
+++ b/shopfloor/tests/test_stock_split.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tests import tagged
from odoo.tests.common import SavepointCase
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 948ec31161..95d59c2b8e 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .common import CommonCase
diff --git a/shopfloor/tests/test_zone_picking_change_pack_lot.py b/shopfloor/tests/test_zone_picking_change_pack_lot.py
index 8818f68391..bfcca57407 100644
--- a/shopfloor/tests/test_zone_picking_change_pack_lot.py
+++ b/shopfloor/tests/test_zone_picking_change_pack_lot.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_zone_picking_base import ZonePickingCommonCase
diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py
index 614cd6a3ff..30818b6c34 100644
--- a/shopfloor/tests/test_zone_picking_select_line.py
+++ b/shopfloor/tests/test_zone_picking_select_line.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_zone_picking_base import ZonePickingCommonCase
diff --git a/shopfloor/tests/test_zone_picking_select_picking_type.py b/shopfloor/tests/test_zone_picking_select_picking_type.py
index a07ee6be9b..76b999d929 100644
--- a/shopfloor/tests/test_zone_picking_select_picking_type.py
+++ b/shopfloor/tests/test_zone_picking_select_picking_type.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_zone_picking_base import ZonePickingCommonCase
diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py
index 33fc91473c..3099b76eb8 100644
--- a/shopfloor/tests/test_zone_picking_set_line_destination.py
+++ b/shopfloor/tests/test_zone_picking_set_line_destination.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_zone_picking_base import ZonePickingCommonCase
diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py
index c8a6fc7933..0f7d2a61d3 100644
--- a/shopfloor/tests/test_zone_picking_start.py
+++ b/shopfloor/tests/test_zone_picking_start.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_zone_picking_base import ZonePickingCommonCase
diff --git a/shopfloor/tests/test_zone_picking_stock_issue.py b/shopfloor/tests/test_zone_picking_stock_issue.py
index 3767f02905..88e66e8f2d 100644
--- a/shopfloor/tests/test_zone_picking_stock_issue.py
+++ b/shopfloor/tests/test_zone_picking_stock_issue.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_zone_picking_base import ZonePickingCommonCase
diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py
index 05c6f8e16e..38e0632339 100644
--- a/shopfloor/tests/test_zone_picking_unload_all.py
+++ b/shopfloor/tests/test_zone_picking_unload_all.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_zone_picking_base import ZonePickingCommonCase
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
index a27e4356af..2e63b6ec43 100644
--- a/shopfloor/tests/test_zone_picking_unload_set_destination.py
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_zone_picking_base import ZonePickingCommonCase
diff --git a/shopfloor/tests/test_zone_picking_unload_single.py b/shopfloor/tests/test_zone_picking_unload_single.py
index e6b67d7889..03f72b41a4 100644
--- a/shopfloor/tests/test_zone_picking_unload_single.py
+++ b/shopfloor/tests/test_zone_picking_unload_single.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_zone_picking_base import ZonePickingCommonCase
diff --git a/shopfloor/tests/test_zone_picking_zero_check.py b/shopfloor/tests/test_zone_picking_zero_check.py
index dba09b2c70..49738d8366 100644
--- a/shopfloor/tests/test_zone_picking_zero_check.py
+++ b/shopfloor/tests/test_zone_picking_zero_check.py
@@ -1,3 +1,5 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .test_zone_picking_base import ZonePickingCommonCase
From 1a27860c2014146f965de5251bd06cc0783b9bd1 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 12 Oct 2020 10:00:10 +0200
Subject: [PATCH 389/940] Update ROADMAP.rst
---
shopfloor/readme/ROADMAP.rst | 2 ++
1 file changed, 2 insertions(+)
diff --git a/shopfloor/readme/ROADMAP.rst b/shopfloor/readme/ROADMAP.rst
index e69de29bb2..7f274ec28f 100644
--- a/shopfloor/readme/ROADMAP.rst
+++ b/shopfloor/readme/ROADMAP.rst
@@ -0,0 +1,2 @@
+* improve documentation
+* split out scenario components to their own modules
From cdd3ca82b765f36c377f1cdd9f180d819f2ec30a Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 13 Oct 2020 10:07:25 +0200
Subject: [PATCH 390/940] shopfloor: fix linting
From de14c208d8c58d7ab821c1ed17de94c61d204dd3 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 13 Oct 2020 16:50:24 +0200
Subject: [PATCH 391/940] shopfloor: update manifest and README
---
shopfloor/README.rst | 203 ++++++++-
shopfloor/__manifest__.py | 5 +-
shopfloor/readme/CONFIGURE.rst | 6 +-
shopfloor/readme/USAGE.rst | 2 +-
shopfloor/static/description/index.html | 530 ++++++++++++++++++++++++
5 files changed, 723 insertions(+), 23 deletions(-)
create mode 100644 shopfloor/static/description/index.html
diff --git a/shopfloor/README.rst b/shopfloor/README.rst
index 4adf2c2512..12590ac995 100644
--- a/shopfloor/README.rst
+++ b/shopfloor/README.rst
@@ -1,24 +1,193 @@
-**This file is going to be generated by oca-gen-addon-readme.**
+=========
+Shopfloor
+=========
-*Manual changes will be overwritten.*
+.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! This file is generated by oca-gen-addon-readme !!
+ !! changes will be overwritten. !!
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
+ :target: https://odoo-community.org/page/development-status
+ :alt: Alpha
+.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
+ :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ :alt: License: AGPL-3
+.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github
+ :target: https://github.com/OCA/wms/tree/13.0/shopfloor
+ :alt: OCA/wms
+.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
+ :target: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor
+ :alt: Translate me on Weblate
+.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
+ :target: https://runbot.odoo-community.org/runbot/285/13.0
+ :alt: Try me on Runbot
-TODO
+|badge1| |badge2| |badge3| |badge4| |badge5|
-Please provide content in the ``readme`` directory:
+Shopfloor is a barcode scanner application for internal warehouse operations.
-* **DESCRIPTION.rst** (required)
-* INSTALL.rst (optional)
-* CONFIGURE.rst (optional)
-* **USAGE.rst** (optional, highly recommended)
-* DEVELOP.rst (optional)
-* ROADMAP.rst (optional)
-* HISTORY.rst (optional, recommended)
-* **CONTRIBUTORS.rst** (optional, highly recommended)
-* CREDITS.rst (optional)
+The application supports scenarios, to relate to Operation Types:
-Content of this README will also be drawn from the addon manifest,
-from keys such as name, authors, maintainers, development_status,
-and license.
+* Cluster Picking
+* Zone Picking
+* Checkout/Packing
+* Delivery
+* Location Content Transfer
+* Single Pack Transfer
-A good, one sentence summary in the manifest is also highly recommended.
+This module provides REST APIs to support the scenarios. It needs a frontend
+to consume the backend APIs and provide screens for users on barcode devices.
+A default front-end application is provided by ``shopfloor_mobile``.
+
+.. IMPORTANT::
+ This is an alpha version, the data model and design can change at any time without warning.
+ Only for development or testing purpose, do not use in production.
+ `More details on development status `_
+
+**Table of contents**
+
+.. contents::
+ :local:
+
+Configuration
+=============
+
+Profiles
+~~~~~~~~
+
+In Inventory / Configuration / Shopfloor / Profiles.
+
+The profiles are used to restrict which menus are shown on the frontend
+application. When a user logs in the scanner application, they have to
+select their profile, so the correct menus are shown.
+
+Menus
+~~~~~
+
+In Inventory / Configuration / Shopfloor / Menus.
+
+The menus are displayed on the frontend application and store the configuration
+of the scenarios. Each menu must use a scenario and defines which Operation Types
+they are allowed to process.
+
+Their profile will restrict the visibility to the profile chosen on the device.
+If a menu has no profile, it is shown in every profile.
+
+Some scenarios may have additional options, which are explained in tooltips.
+
+Logs retention
+~~~~~~~~~~~~~~
+
+Logs are kept in database for every REST requests made by a client application.
+They can be used for debugging and monitoring of the activity.
+
+The Logs menu is shown only with Developer tools (``?debug=1``) activated.
+
+By default, Shopfloor logs are kept 30 days.
+You can change the duration of the retention by changing the System Parameter
+``shopfloor.log.retention.days``.
+
+If the value is set to 0, the logs are not stored at all.
+
+Logged data is: request URL and method, parameters, headers, result or error.
+
+Usage
+=====
+
+An API key is created in the Demo data (for development), using
+the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DAC
+
+Curl example::
+
+ curl -X POST "http://localhost:8069/shopfloor/app/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC"
+
+Known issues / Roadmap
+======================
+
+* improve documentation
+* split out scenario components to their own modules
+
+Changelog
+=========
+
+13.0.1.0.0
+~~~~~~~~~~
+
+First official version.
+
+Bug Tracker
+===========
+
+Bugs are tracked on `GitHub Issues `_.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us smashing it by providing a detailed and welcomed
+`feedback `_.
+
+Do not contact contributors directly about support or help with technical issues.
+
+Credits
+=======
+
+Authors
+~~~~~~~
+
+* Camptocamp
+* BCIM
+* Akretion
+
+Contributors
+~~~~~~~~~~~~
+
+* Guewen Baconnier
+* Simone Orsi
+* Sébastien Alix
+* Alexandre Fayolle
+* Benoit Guillot
+* Thierry Ducrest
+
+Design
+~~~~~~
+
+* Joël Grand-Guillaume
+* Jacques-Etienne Baudoux
+
+Other credits
+~~~~~~~~~~~~~
+
+**Financial support**
+
+* Cosanum
+* Camptocamp R&D
+* Akretion R&D
+
+Maintainers
+~~~~~~~~~~~
+
+This module is maintained by the OCA.
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+.. |maintainer-guewen| image:: https://github.com/guewen.png?size=40px
+ :target: https://github.com/guewen
+ :alt: guewen
+.. |maintainer-simahawk| image:: https://github.com/simahawk.png?size=40px
+ :target: https://github.com/simahawk
+ :alt: simahawk
+.. |maintainer-sebalix| image:: https://github.com/sebalix.png?size=40px
+ :target: https://github.com/sebalix
+ :alt: sebalix
+
+Current `maintainers `__:
+
+|maintainer-guewen| |maintainer-simahawk| |maintainer-sebalix|
+
+This module is part of the `OCA/wms `_ project on GitHub.
+
+You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 899cf0321b..6a90cb2e79 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -9,8 +9,9 @@
"version": "13.0.1.1.0",
"development_status": "Alpha",
"category": "Inventory",
- "website": "https://odoo-community.org",
- "author": "Akretion, BCIM, Camptocamp, Odoo Community Association (OCA)",
+ "website": "https://github.com/OCA/wms",
+ "author": "Camptocamp, BCIM, Akretion, Odoo Community Association (OCA)",
+ "maintainers": ["guewen", "simahawk", "sebalix"],
"license": "AGPL-3",
"application": True,
"depends": [
diff --git a/shopfloor/readme/CONFIGURE.rst b/shopfloor/readme/CONFIGURE.rst
index 059a7dda31..92102a8b42 100644
--- a/shopfloor/readme/CONFIGURE.rst
+++ b/shopfloor/readme/CONFIGURE.rst
@@ -1,5 +1,5 @@
Profiles
---------
+~~~~~~~~
In Inventory / Configuration / Shopfloor / Profiles.
@@ -8,7 +8,7 @@ application. When a user logs in the scanner application, they have to
select their profile, so the correct menus are shown.
Menus
------
+~~~~~
In Inventory / Configuration / Shopfloor / Menus.
@@ -22,7 +22,7 @@ If a menu has no profile, it is shown in every profile.
Some scenarios may have additional options, which are explained in tooltips.
Logs retention
---------------
+~~~~~~~~~~~~~~
Logs are kept in database for every REST requests made by a client application.
They can be used for debugging and monitoring of the activity.
diff --git a/shopfloor/readme/USAGE.rst b/shopfloor/readme/USAGE.rst
index 2d5767567e..d628404b12 100644
--- a/shopfloor/readme/USAGE.rst
+++ b/shopfloor/readme/USAGE.rst
@@ -3,4 +3,4 @@ the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DA
Curl example::
- curl -X POST "http://localhost:8069/shopfloor/shopfloor/get_pack" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" -d "{\"pack_name\":\"string\"}"
+ curl -X POST "http://localhost:8069/shopfloor/app/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC"
diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html
new file mode 100644
index 0000000000..62e8f7eade
--- /dev/null
+++ b/shopfloor/static/description/index.html
@@ -0,0 +1,530 @@
+
+
+
+
+
+
+Shopfloor
+
+
+
+
+
Shopfloor
+
+
+
+
Shopfloor is a barcode scanner application for internal warehouse operations.
+
The application supports scenarios, to relate to Operation Types:
+
+Cluster Picking
+Zone Picking
+Checkout/Packing
+Delivery
+Location Content Transfer
+Single Pack Transfer
+
+
This module provides REST APIs to support the scenarios. It needs a frontend
+to consume the backend APIs and provide screens for users on barcode devices.
+A default front-end application is provided by shopfloor_mobile .
+
+
Important
+
This is an alpha version, the data model and design can change at any time without warning.
+Only for development or testing purpose, do not use in production.
+More details on development status
+
+
Table of contents
+
+
+
+
+
+
In Inventory / Configuration / Shopfloor / Profiles.
+
The profiles are used to restrict which menus are shown on the frontend
+application. When a user logs in the scanner application, they have to
+select their profile, so the correct menus are shown.
+
+
+
+
+
Logs are kept in database for every REST requests made by a client application.
+They can be used for debugging and monitoring of the activity.
+
The Logs menu is shown only with Developer tools (?debug=1 ) activated.
+
By default, Shopfloor logs are kept 30 days.
+You can change the duration of the retention by changing the System Parameter
+shopfloor.log.retention.days .
+
If the value is set to 0, the logs are not stored at all.
+
Logged data is: request URL and method, parameters, headers, result or error.
+
+
+
+
+
An API key is created in the Demo data (for development), using
+the Demo user. The key to use in the HTTP header API-KEY is: 72B044F7AC780DAC
+
Curl example:
+
+curl -X POST "http://localhost:8069/shopfloor/app/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC"
+
+
+
+
+
+improve documentation
+split out scenario components to their own modules
+
+
+
+
+
+
+
First official version.
+
+
+
+
+
Bugs are tracked on GitHub Issues .
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us smashing it by providing a detailed and welcomed
+feedback .
+
Do not contact contributors directly about support or help with technical issues.
+
+
+
+
+
+
+Camptocamp
+BCIM
+Akretion
+
+
+
+
+
+
+
Financial support
+
+Cosanum
+Camptocamp R&D
+Akretion R&D
+
+
+
+
+
This module is maintained by the OCA.
+
+
OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
Current maintainers :
+
+
This module is part of the OCA/wms project on GitHub.
+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute .
+
+
+
+
+
From c111bec9df470c45a3639ba2e02d907db763aadd Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 13 Oct 2020 17:46:40 +0200
Subject: [PATCH 392/940] shopfloor: fix permission in tests
---
shopfloor/tests/common.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 88bddd0ba2..df5aafd7d2 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -365,9 +365,9 @@ def _create_picking_batch(cls, products):
:param products: list of list of BatchProduct. The outer list creates
pickings and the innerr list creates moves in these pickings
"""
- batch_form = Form(cls.env["stock.picking.batch"])
+ batch_form = Form(cls.env["stock.picking.batch"].sudo())
for transfer in products:
- picking_form = Form(cls.env["stock.picking"])
+ picking_form = Form(cls.env["stock.picking"].sudo())
picking_form.picking_type_id = cls.picking_type
picking_form.location_id = cls.stock_location
picking_form.location_dest_id = cls.packing_location
From 6b51c416a588c8948b96a4f1f15091aff295003f Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 19 Oct 2020 10:52:49 +0200
Subject: [PATCH 393/940] backend: save the form only when all moves have been
added
---
shopfloor/tests/common.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index df5aafd7d2..0eb6612b5b 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -379,7 +379,7 @@ def _create_picking_batch(cls, products):
with picking_form.move_ids_without_package.new() as move:
move.product_id = product
move.product_uom_qty = quantity
- picking = picking_form.save()
+ picking = picking_form.save()
batch_form.picking_ids.add(picking)
batch = batch_form.save()
From be5295bb2746a74e219612048fd7991ac6b86a0a Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 19 Oct 2020 14:57:52 +0200
Subject: [PATCH 394/940] delivery: fix failing test
When we call the "done" endpoint and a backorder is created, the test
ensure the backorder is out of scope: nothing in the "done" endpoint
ensures that the backorder is assigned; it calls
"picking.action_done()": this check is testing the behavior of
"action_done" which is supposed to be tested in the stock module.
Actually, this condition currently fails because of another module
(probably depends of the configuration), but this is not what we want
to test here.
---
shopfloor/tests/test_delivery_done.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/shopfloor/tests/test_delivery_done.py b/shopfloor/tests/test_delivery_done.py
index e2ef593719..8c49038db3 100644
--- a/shopfloor/tests/test_delivery_done.py
+++ b/shopfloor/tests/test_delivery_done.py
@@ -100,5 +100,4 @@ def test_done_some_qty_done_confirm(self):
self.assertEqual(self.picking.move_lines, self.raw_move)
backorder = self.picking.backorder_ids
self.assertTrue(backorder)
- self.assertEqual(backorder.state, "assigned")
self.assertEqual(self.pack1_moves.picking_id, backorder)
From 7b07e52ba06c1df9d6c8c8b60152fb344fb65364 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 20 Oct 2020 10:33:32 +0200
Subject: [PATCH 395/940] backend: fix flaky tests
---
shopfloor/tests/test_cluster_picking_scan.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 000aeb6b99..03dc024de0 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -296,8 +296,12 @@ def setUpClassBaseData(cls, *args, **kwargs):
[cls.BatchProduct(product=cls.product_a, quantity=10)],
]
)
- cls.one_line_picking = cls.batch.picking_ids[0]
- cls.two_lines_picking = cls.batch.picking_ids[1]
+ cls.one_line_picking = cls.batch.picking_ids.filtered(
+ lambda picking: len(picking.move_lines) == 1
+ )
+ cls.two_lines_picking = cls.batch.picking_ids.filtered(
+ lambda picking: len(picking.move_lines) == 2
+ )
cls.bin1 = cls.env["stock.quant.package"].create({})
cls.bin2 = cls.env["stock.quant.package"].create({})
From 745a1f778c6ec1904ea4152c2402bd0e71cb0f36 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 20 Oct 2020 10:56:20 +0200
Subject: [PATCH 396/940] backend: fix more flaky tests
---
.../tests/test_cluster_picking_unload.py | 22 ++++++++++++++-----
1 file changed, 16 insertions(+), 6 deletions(-)
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 6616b1cb52..dcd379f6f7 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -158,14 +158,22 @@ def test_set_destination_all_ok(self):
def test_set_destination_all_remaining_lines(self):
"""Set destination on all lines for a part of the batch"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ one_line_picking = self.batch.picking_ids.filtered(
+ lambda picking: len(picking.move_lines) == 1
+ )
+ two_lines_picking = self.batch.picking_ids.filtered(
+ lambda picking: len(picking.move_lines) == 2
+ )
+ move_lines = one_line_picking.move_line_ids + two_lines_picking.move_line_ids
# Put destination packages, the whole quantity on lines and a similar
# destination (when /set_destination_all is called, all the lines to
# unload must have the same destination).
# However, we keep a line without qty_done and destination package,
# so when the dest location is set, the endpoint should route back
# to the 'start_line' state to work on the remaining line.
- lines_to_unload = move_lines[:2]
+ lines_to_unload = (
+ one_line_picking.move_line_ids + two_lines_picking.move_line_ids[0]
+ )
self._set_dest_package_and_done(lines_to_unload, self.bin1)
lines_to_unload.write({"location_dest_id": self.packing_location.id})
@@ -179,8 +187,6 @@ def test_set_destination_all_remaining_lines(self):
# Since the whole batch is not complete, state should not be done.
# The picking with one line should be "done" because we unloaded its line.
# The second one still has a line to pick.
- one_line_picking = self.batch.picking_ids[0]
- two_lines_picking = self.batch.picking_ids[1]
self.assertRecordValues(one_line_picking, [{"state": "done"}])
self.assertRecordValues(two_lines_picking, [{"state": "assigned"}])
self.assertRecordValues(
@@ -468,8 +474,12 @@ def setUpClassBaseData(cls, *args, **kwargs):
cls._set_dest_package_and_done(cls.bin2_lines, cls.bin2)
cls.bin1_lines.write({"location_dest_id": cls.packing_a_location.id})
cls.bin2_lines.write({"location_dest_id": cls.packing_b_location.id})
- cls.one_line_picking = cls.batch.picking_ids[0]
- cls.two_lines_picking = cls.batch.picking_ids[1]
+ cls.one_line_picking = cls.batch.picking_ids.filtered(
+ lambda picking: len(picking.move_lines) == 1
+ )
+ cls.two_lines_picking = cls.batch.picking_ids.filtered(
+ lambda picking: len(picking.move_lines) == 2
+ )
def test_unload_scan_destination_ok(self):
"""Endpoint /unload_scan_destination is called, result ok"""
From 0b224759f56928ffff9443da94dcf10963d2ca17 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 20 Oct 2020 12:24:02 +0200
Subject: [PATCH 397/940] backend: fix more flaky tests by forcing order of
moves in tests
---
shopfloor/tests/test_cluster_picking_scan.py | 2 +-
.../tests/test_cluster_picking_unload.py | 65 ++++++++++---------
2 files changed, 37 insertions(+), 30 deletions(-)
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
index 03dc024de0..49326a0f2b 100644
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ b/shopfloor/tests/test_cluster_picking_scan.py
@@ -593,7 +593,7 @@ def test_scan_destination_pack_zero_check_disabled(self):
},
)
- next_line = self.batch.picking_ids.move_line_ids[1]
+ next_line = self.two_lines_picking.move_line_ids[0]
# continue to the next one, no zero check
self.assert_response(
response,
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index dcd379f6f7..43ee6ad58c 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -23,6 +23,24 @@ def setUpClassBaseData(cls, *args, **kwargs):
]
)
cls._simulate_batch_selected(cls.batch)
+
+ cls.one_line_picking = cls.batch.picking_ids.filtered(
+ lambda picking: len(picking.move_lines) == 1
+ )
+ cls.two_lines_picking = cls.batch.picking_ids.filtered(
+ lambda picking: len(picking.move_lines) == 2
+ )
+ two_lines_product_a = cls.two_lines_picking.move_line_ids.filtered(
+ lambda line: line.product_id == cls.product_a
+ )
+ two_lines_product_b = cls.two_lines_picking.move_line_ids - two_lines_product_a
+ # force order of move lines to use in tests
+ cls.move_lines = (
+ cls.one_line_picking.move_line_ids
+ + two_lines_product_a
+ + two_lines_product_b
+ )
+
cls.bin1 = cls.env["stock.quant.package"].create({})
cls.bin2 = cls.env["stock.quant.package"].create({})
cls.packing_a_location = (
@@ -65,7 +83,7 @@ class ClusterPickingPrepareUnloadCase(ClusterPickingUnloadingCommonCase):
def test_prepare_unload_all_same_dest(self):
"""All move lines have the same destination location"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ move_lines = self.move_lines
self._set_dest_package_and_done(move_lines[:2], self.bin1)
self._set_dest_package_and_done(move_lines[2:], self.bin2)
move_lines.write({"location_dest_id": self.packing_location.id})
@@ -80,7 +98,7 @@ def test_prepare_unload_all_same_dest(self):
def test_prepare_unload_different_dest(self):
"""All move lines have different destination locations"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ move_lines = self.move_lines
self._set_dest_package_and_done(move_lines[:2], self.bin1)
self._set_dest_package_and_done(move_lines[2:], self.bin2)
move_lines[:1].write({"location_dest_id": self.packing_a_location.id})
@@ -106,7 +124,7 @@ class ClusterPickingSetDestinationAllCase(ClusterPickingUnloadingCommonCase):
def test_set_destination_all_ok(self):
"""Set destination on all lines for the full batch and end the process"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ move_lines = self.move_lines
# put destination packages, the whole quantity on lines and a similar
# destination (when /set_destination_all is called, all the lines to
# unload must have the same destination)
@@ -158,22 +176,13 @@ def test_set_destination_all_ok(self):
def test_set_destination_all_remaining_lines(self):
"""Set destination on all lines for a part of the batch"""
- one_line_picking = self.batch.picking_ids.filtered(
- lambda picking: len(picking.move_lines) == 1
- )
- two_lines_picking = self.batch.picking_ids.filtered(
- lambda picking: len(picking.move_lines) == 2
- )
- move_lines = one_line_picking.move_line_ids + two_lines_picking.move_line_ids
# Put destination packages, the whole quantity on lines and a similar
# destination (when /set_destination_all is called, all the lines to
# unload must have the same destination).
# However, we keep a line without qty_done and destination package,
# so when the dest location is set, the endpoint should route back
# to the 'start_line' state to work on the remaining line.
- lines_to_unload = (
- one_line_picking.move_line_ids + two_lines_picking.move_line_ids[0]
- )
+ lines_to_unload = self.move_lines[:2]
self._set_dest_package_and_done(lines_to_unload, self.bin1)
lines_to_unload.write({"location_dest_id": self.packing_location.id})
@@ -187,16 +196,16 @@ def test_set_destination_all_remaining_lines(self):
# Since the whole batch is not complete, state should not be done.
# The picking with one line should be "done" because we unloaded its line.
# The second one still has a line to pick.
- self.assertRecordValues(one_line_picking, [{"state": "done"}])
- self.assertRecordValues(two_lines_picking, [{"state": "assigned"}])
+ self.assertRecordValues(self.one_line_picking, [{"state": "done"}])
+ self.assertRecordValues(self.two_lines_picking, [{"state": "assigned"}])
self.assertRecordValues(
- move_lines,
+ self.move_lines,
[
{
"shopfloor_unloaded": True,
"qty_done": 10,
"state": "done",
- "picking_id": one_line_picking.id,
+ "picking_id": self.one_line_picking.id,
"location_dest_id": self.packing_location.id,
},
{
@@ -204,14 +213,14 @@ def test_set_destination_all_remaining_lines(self):
"qty_done": 10,
# will be done when the second line of the picking is unloaded
"state": "assigned",
- "picking_id": two_lines_picking.id,
+ "picking_id": self.two_lines_picking.id,
"location_dest_id": self.packing_location.id,
},
{
"shopfloor_unloaded": False,
"qty_done": 0,
"state": "assigned",
- "picking_id": two_lines_picking.id,
+ "picking_id": self.two_lines_picking.id,
"location_dest_id": self.packing_location.id,
},
],
@@ -222,13 +231,13 @@ def test_set_destination_all_remaining_lines(self):
# the remaining move line still needs to be picked
response,
next_state="start_line",
- data=self._line_data(move_lines[2]),
+ data=self._line_data(self.move_lines[2]),
message={"body": "Batch Transfer line done", "message_type": "success"},
)
def test_set_destination_all_but_different_dest(self):
"""Endpoint was called but destinations are different"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ move_lines = self.move_lines
self._set_dest_package_and_done(move_lines, self.bin1)
move_lines[:2].write({"location_dest_id": self.packing_a_location.id})
move_lines[2:].write({"location_dest_id": self.packing_b_location.id})
@@ -248,7 +257,7 @@ def test_set_destination_all_but_different_dest(self):
def test_set_destination_all_error_location_not_found(self):
"""Endpoint called with a barcode not existing for a location"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ move_lines = self.move_lines
self._set_dest_package_and_done(move_lines, self.bin1)
move_lines.write({"location_dest_id": self.packing_a_location.id})
@@ -274,7 +283,7 @@ def test_set_destination_all_error_location_invalid(self):
It is invalid when the location is not the destination location or
sublocation of the picking type.
"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ move_lines = self.move_lines
self._set_dest_package_and_done(move_lines, self.bin1)
move_lines.write({"location_dest_id": self.packing_a_location.id})
@@ -300,7 +309,7 @@ def test_set_destination_all_error_location_move_invalid(self):
It is invalid when the location is not the destination location or
sublocation of move line's move
"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ move_lines = self.move_lines
self._set_dest_package_and_done(move_lines, self.bin1)
move_lines.write({"location_dest_id": self.packing_a_location.id})
move_lines[0].move_id.location_dest_id = self.packing_a_location
@@ -323,7 +332,7 @@ def test_set_destination_all_error_location_move_invalid(self):
def test_set_destination_all_need_confirmation(self):
"""Endpoint called with a barcode for another (valid) location"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ move_lines = self.move_lines
self._set_dest_package_and_done(move_lines, self.bin1)
move_lines.write({"location_dest_id": self.packing_a_location.id})
@@ -342,7 +351,7 @@ def test_set_destination_all_need_confirmation(self):
def test_set_destination_all_with_confirmation(self):
"""Endpoint called with a barcode for another (valid) location, confirm"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ move_lines = self.move_lines
self._set_dest_package_and_done(move_lines, self.bin1)
move_lines.write({"location_dest_id": self.packing_a_location.id})
@@ -380,7 +389,7 @@ class ClusterPickingUnloadSplitCase(ClusterPickingUnloadingCommonCase):
def test_unload_split_ok(self):
"""Call /unload_split and continue to unload single"""
- move_lines = self.batch.mapped("picking_ids.move_line_ids")
+ move_lines = self.move_lines
# put destination packages, the whole quantity on lines and a similar
# destination (when /set_destination_all is called, all the lines to
# unload must have the same destination)
@@ -413,7 +422,6 @@ class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase):
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
- cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids")
cls._set_dest_package_and_done(cls.move_lines, cls.bin1)
cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id})
cls.move_lines[2:].write({"location_dest_id": cls.packing_b_location.id})
@@ -467,7 +475,6 @@ class ClusterPickingUnloadScanDestinationCase(ClusterPickingUnloadingCommonCase)
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
- cls.move_lines = cls.batch.mapped("picking_ids.move_line_ids")
cls.bin1_lines = cls.move_lines[:1]
cls.bin2_lines = cls.move_lines[1:]
cls._set_dest_package_and_done(cls.bin1_lines, cls.bin1)
From 4e1bc6032d4b8c03d705a13bcdc7faac4dc51ee7 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 20 Oct 2020 13:04:20 +0200
Subject: [PATCH 398/940] cluster picking: make sort of packages deterministic
In tests, the order would not be always the same with records in memory.
Also, ensure the destination match the packages in the tests, it doesn't
make sense to have several lines in a package with different destination
locations.
---
shopfloor/services/cluster_picking.py | 2 +-
shopfloor/tests/test_cluster_picking_unload.py | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index ed3a1b77e7..33c3da1d3e 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -674,7 +674,7 @@ def _lines_to_unload(self, batch):
def _bin_packages_to_unload(self, batch):
lines = self._lines_to_unload(batch)
- packages = lines.mapped("result_package_id")
+ packages = lines.mapped("result_package_id").sorted()
return packages
def _next_bin_package_for_unload_single(self, batch):
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 43ee6ad58c..5d9ed6f0bb 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -101,8 +101,8 @@ def test_prepare_unload_different_dest(self):
move_lines = self.move_lines
self._set_dest_package_and_done(move_lines[:2], self.bin1)
self._set_dest_package_and_done(move_lines[2:], self.bin2)
- move_lines[:1].write({"location_dest_id": self.packing_a_location.id})
- move_lines[:1].write({"location_dest_id": self.packing_b_location.id})
+ move_lines[:2].write({"location_dest_id": self.packing_a_location.id})
+ move_lines[2:].write({"location_dest_id": self.packing_b_location.id})
response = self.service.dispatch(
"prepare_unload", params={"picking_batch_id": self.batch.id}
)
From ea1abd2eefd0d4801892cf75e4a7169c118d5473 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 19 Oct 2020 14:55:07 +0200
Subject: [PATCH 399/940] zone_picking/unload_set_destination: fix move.state =
partially_available handling
---
shopfloor/models/stock_move.py | 13 +++-
shopfloor/services/zone_picking.py | 7 ++
...est_zone_picking_unload_set_destination.py | 76 +++++++++++++++++++
3 files changed, 94 insertions(+), 2 deletions(-)
diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py
index 2794cf4449..6c8f2abb4d 100644
--- a/shopfloor/models/stock_move.py
+++ b/shopfloor/models/stock_move.py
@@ -18,13 +18,22 @@ def split_other_move_lines(self, move_lines, intersection=False):
other_move_lines = self.move_line_ids & move_lines
else:
other_move_lines = self.move_line_ids - move_lines
- if other_move_lines:
- qty_to_split = sum(other_move_lines.mapped("product_uom_qty"))
+ if other_move_lines or self.state == "partially_available":
+ if intersection:
+ # TODO @sebalix: please check if we can abandon the flag.
+ # Thi behavior can be achieved by passing all move lines
+ # as done at zone_picking.py:1293
+ qty_to_split = sum(other_move_lines.mapped("product_uom_qty"))
+ else:
+ qty_to_split = self.product_uom_qty - sum(
+ move_lines.mapped("product_uom_qty")
+ )
backorder_move_id = self._split(qty_to_split)
backorder_move = self.browse(backorder_move_id)
backorder_move.move_line_ids = other_move_lines
backorder_move._recompute_state()
backorder_move._action_assign()
+ self._recompute_state()
return backorder_move
return False
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 8201eaec44..ae09596710 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1287,9 +1287,16 @@ def unload_set_destination(
self._write_destination_on_lines(buffer_lines, location)
# set lines to done + refresh buffer lines (should be empty)
moves = buffer_lines.mapped("move_id")
+ # split move lines to a backorder move
+ # if quantity is not fully satisfied
+ for move in moves:
+ move.split_other_move_lines(buffer_lines & move.move_line_ids)
+
moves.extract_and_action_done()
buffer_lines = self._find_buffer_move_lines(zone_location, picking_type)
+
if buffer_lines:
+ # TODO: return success message if line has been processed
return self._response_for_unload_single(
zone_location, picking_type, first(buffer_lines)
)
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
index 2e63b6ec43..0e85617345 100644
--- a/shopfloor/tests/test_zone_picking_unload_set_destination.py
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -10,6 +10,25 @@ class ZonePickingUnloadSetDestinationCase(ZonePickingCommonCase):
"""
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.product_g = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product G",
+ "type": "product",
+ "default_code": "G",
+ "barcode": "G",
+ "weight": 7,
+ }
+ )
+ )
+ cls.picking_g = cls._create_picking(lines=[(cls.product_g, 40)])
+ cls._update_qty_in_location(cls.zone_sublocation1, cls.product_g, 32)
+
def test_unload_set_destination_wrong_parameters(self):
zone_location = self.zone_location
picking_type = self.picking1.picking_type_id
@@ -300,3 +319,60 @@ def test_unload_set_destination_ok_buffer_not_empty(self):
buffer_line,
popup=completion_info_popup,
)
+
+ def test_unload_set_destination_partially_available_backorder(self):
+ zone_location = self.zone_location
+ picking_type = self.picking_g.picking_type_id
+ self.assertEqual(self.picking_g.move_lines[0].product_uom_qty, 40)
+ self.picking_g.action_assign()
+ move_line = self.picking_g.move_line_ids
+ self.assertEqual(move_line.product_uom_qty, 32)
+ self.assertEqual(move_line.move_id.state, "partially_available")
+ packing_sublocation = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Packing sublocation",
+ "location_id": self.packing_location.id,
+ "barcode": "PACKING_SUBLOCATION",
+ }
+ )
+ )
+ # set the destination package
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line,
+ move_line.product_uom_qty,
+ self.free_package,
+ )
+ response = self.service.dispatch(
+ "unload_set_destination",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "package_id": self.free_package.id,
+ "barcode": packing_sublocation.barcode,
+ "confirmation": True,
+ },
+ )
+ # check data
+ # move line has been moved to a new picking
+ self.assertEqual(move_line.move_id.picking_id, self.picking_g.backorder_ids[0])
+ # the old picking contains a new line w/ the rest of the qty
+ # that couldn't be processed
+ self.assertEqual(self.picking_g.move_lines[0].product_uom_qty, 8)
+ self.assertEqual(self.picking_g.state, "confirmed")
+ # the line has been processed
+ self.assertEqual(move_line.location_dest_id, packing_sublocation)
+ self.assertEqual(move_line.move_id.state, "done")
+ # check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.buffer_complete(),
+ )
From 0b54628dc533fa40eb6f0a984b886c268db68c89 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 14 Oct 2020 11:09:32 +0200
Subject: [PATCH 400/940] shopfloor: improve logs with exception detail +
severity
---
shopfloor/models/shopfloor_log.py | 71 +++++++-
shopfloor/services/service.py | 31 +++-
shopfloor/tests/test_db_logging.py | 218 ++++++++++++++++++++++++
shopfloor/views/shopfloor_log_views.xml | 9 +-
4 files changed, 320 insertions(+), 9 deletions(-)
create mode 100644 shopfloor/tests/test_db_logging.py
diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py
index 9a0401cedd..9e04a0f1ce 100644
--- a/shopfloor/models/shopfloor_log.py
+++ b/shopfloor/models/shopfloor_log.py
@@ -3,7 +3,7 @@
import logging
from datetime import datetime, timedelta
-from odoo import fields, models
+from odoo import api, fields, models, tools
_logger = logging.getLogger(__name__)
@@ -14,6 +14,14 @@ class ShopfloorLog(models.Model):
_order = "id desc"
DEFAULT_RETENTION = 30 # days
+ EXCEPTION_SEVERITY_MAPPING = {
+ "odoo.exceptions.UserError": "functional",
+ "odoo.exceptions.ValidationError": "functional",
+ # something broken somewhere
+ "ValueError": "severe",
+ "AttributeError": "severe",
+ "UnboundLocalError": "severe",
+ }
request_url = fields.Char(readonly=True, string="Request URL")
request_method = fields.Char(readonly=True)
@@ -21,9 +29,70 @@ class ShopfloorLog(models.Model):
headers = fields.Text(readonly=True)
result = fields.Text(readonly=True)
error = fields.Text(readonly=True)
+ exception_name = fields.Char(readonly=True, string="Exception")
+ exception_message = fields.Text(readonly=True)
state = fields.Selection(
selection=[("success", "Success"), ("failed", "Failed")], readonly=True,
)
+ severity = fields.Selection(
+ selection=[
+ ("functional", "Functional"),
+ ("warning", "Warning"),
+ ("severe", "Severe"),
+ ],
+ compute="_compute_severity",
+ store=True,
+ # Grant specific override services' dispatch_exception override
+ # or via UI: user can classify errors as preferred on demand
+ # (maybe using mass_edit)
+ readonly=False,
+ )
+
+ @api.depends("state", "exception_name", "error")
+ def _compute_severity(self):
+ for rec in self:
+ rec.severity = rec.severity or rec._get_severity()
+
+ def _get_severity(self):
+ if not self.exception_name:
+ return False
+ mapping = self._get_exception_severity_mapping()
+ return mapping.get(self.exception_name, "warning")
+
+ def _get_exception_severity_mapping_param(self):
+ param = (
+ self.env["ir.config_parameter"]
+ .sudo()
+ .get_param("shopfloor.log.severity.exception.mapping")
+ )
+ return param.strip() if param else ""
+
+ @tools.ormcache("self._get_exception_severity_mapping_param()")
+ def _get_exception_severity_mapping(self):
+ mapping = self.EXCEPTION_SEVERITY_MAPPING.copy()
+ param = self._get_exception_severity_mapping_param()
+ if not param:
+ return mapping
+ # param should be in the form
+ # `[module.dotted.path.]ExceptionName:severity,ExceptionName:severity`
+ for rule in param.split(","):
+ if not rule.strip():
+ continue
+ exc_name = severity = None
+ try:
+ exc_name, severity = [x.strip() for x in rule.split(":")]
+ if not exc_name or not severity:
+ raise ValueError
+ except ValueError:
+ _logger.exception(
+ "Could not convert System Parameter"
+ " 'shopfloor.log.severity.exception.mapping' to mapping."
+ " The following rule will be ignored: %s",
+ rule,
+ )
+ if exc_name and severity:
+ mapping[exc_name] = severity
+ return mapping
def _logs_retention_days(self):
retention = self.DEFAULT_RETENTION
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 6bce31321b..791b567d4d 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -100,15 +100,21 @@ def _dispatch_exception(
self, exception_klass, orig_exception, _id=None, params=None
):
tb = traceback.format_exc()
+ # TODO: how to test this? Cannot rollback nor use another cursor
self.env.cr.rollback()
with registry(self.env.cr.dbname).cursor() as cr:
env = self.env(cr=cr)
- log_entry = self._log_call_in_db(env, request, _id, params, error=tb)
+ log_entry = self._log_call_in_db(
+ env, request, _id, params, traceback=tb, orig_exception=orig_exception
+ )
log_entry_url = self._get_log_entry_url(log_entry)
# UserError and alike have `name` attribute to store the msg
- exc_msg = getattr(orig_exception, "name", str(orig_exception))
+ exc_msg = self._get_exception_message(orig_exception)
raise exception_klass(exc_msg, log_entry_url) from orig_exception
+ def _get_exception_message(self, exception):
+ return getattr(exception, "name", str(exception))
+
def _get_log_entry_url(self, entry):
base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
url_params = {
@@ -124,7 +130,7 @@ def _get_log_entry_url(self, entry):
def _log_call_header_strip(self):
return ("Cookie", "Api-Key")
- def _log_call_in_db_values(self, _request, _id, params, result=None, error=None):
+ def _log_call_in_db_values(self, _request, _id, params, **kw):
httprequest = _request.httprequest
headers = dict(httprequest.headers)
for header_key in self._log_call_header_strip:
@@ -132,6 +138,17 @@ def _log_call_in_db_values(self, _request, _id, params, result=None, error=None)
headers[header_key] = ""
if _id:
params = dict(params, _id=_id)
+
+ result = kw.get("result")
+ error = kw.get("traceback")
+ orig_exception = kw.get("orig_exception")
+ exception_name = None
+ exception_message = None
+ if orig_exception:
+ exception_name = orig_exception.__class__.__name__
+ if hasattr(orig_exception, "__module__"):
+ exception_name = orig_exception.__module__ + "." + exception_name
+ exception_message = self._get_exception_message(orig_exception)
return {
"request_url": httprequest.url,
"request_method": httprequest.method,
@@ -139,13 +156,13 @@ def _log_call_in_db_values(self, _request, _id, params, result=None, error=None)
"headers": headers,
"result": result,
"error": error,
+ "exception_name": exception_name,
+ "exception_message": exception_message,
"state": "success" if result else "failed",
}
- def _log_call_in_db(self, env, _request, _id, params, result=None, error=None):
- values = self._log_call_in_db_values(
- _request, _id, params, result=result, error=error
- )
+ def _log_call_in_db(self, env, _request, _id, params, **kw):
+ values = self._log_call_in_db_values(_request, _id, params, **kw)
if not values:
return
return env["shopfloor.log"].sudo().create(values)
diff --git a/shopfloor/tests/test_db_logging.py b/shopfloor/tests/test_db_logging.py
new file mode 100644
index 0000000000..f6a4a8ed48
--- /dev/null
+++ b/shopfloor/tests/test_db_logging.py
@@ -0,0 +1,218 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+# from urllib.parse import urlparse
+# import mock
+
+from odoo import exceptions
+
+from odoo.addons.website.tools import MockRequest
+
+from .common import CommonCase
+
+
+class DBLoggingCaseBase(CommonCase):
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout")
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.picking_type = cls.menu.picking_type_ids
+ with cls.work_on_services(cls, menu=cls.menu, profile=cls.profile) as work:
+ cls.service = work.component(usage="checkout")
+ cls.log_model = cls.env["shopfloor.log"].sudo()
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.wh.sudo().delivery_steps = "pick_pack_ship"
+ cls.picking = cls._create_picking()
+ cls._fill_stock_for_moves(cls.picking.move_lines, in_package=True)
+ cls.picking.action_assign()
+
+ def _get_mocked_request(self, httprequest=None, extra_headers=None):
+ mocked_request = MockRequest(self.env)
+ # Make sure headers are there, no header in default mocked request :(
+ headers = {
+ "Cookie": "IaMaCookie!",
+ "Api-Key": "I_MUST_STAY_SECRET",
+ }
+ headers.update(extra_headers or {})
+ httprequest = httprequest or {}
+ httprequest["headers"] = headers
+ mocked_request.request["httprequest"] = httprequest
+ return mocked_request
+
+
+class DBLoggingCase(DBLoggingCaseBase):
+ def test_no_log_entry(self):
+ self.service._log_calls_in_db = False
+ log_entry_count = self.log_model.search_count([])
+ with self._get_mocked_request():
+ resp = self.service.dispatch(
+ "scan_document", params={"barcode": self.picking.name}
+ )
+ self.assertNotIn("log_entry_url", resp)
+ self.assertFalse(self.log_model.search_count([]) > log_entry_count)
+
+ def test_log_entry(self):
+ log_entry_count = self.log_model.search_count([])
+ with self._get_mocked_request():
+ resp = self.service.dispatch(
+ "scan_document", params={"barcode": self.picking.name}
+ )
+ self.assertIn("log_entry_url", resp)
+ self.assertTrue(self.log_model.search_count([]) > log_entry_count)
+
+ # TODO: this is very tricky because when the exception is raised
+ # the transaction is explicitly rolled back and then our test env is gone
+ # and everything right after is broken.
+ # To fully test this we need a different test class setup and advanced mocking
+ # and/or rewrite code so that we can it properly.
+ # def test_log_exception(self):
+ # mock_path = \
+ # "odoo.addons.shopfloor.services.checkout.Checkout.scan_document"
+ # log_entry_count = self.log_model.search_count([])
+ # with self._get_mocked_request():
+ # with mock.patch(mock_path, autospec=True) as mocked:
+ # exc = exceptions.UserError("Sorry, you broke it!")
+ # mocked.side_effect = exc
+ # resp = self.service.dispatch(
+ # "scan_document", params={"barcode": self.picking.name})
+ # self.assertIn("log_entry_url", resp)
+ # self.assertTrue(self.log_model.search_count([]) > log_entry_count)
+ # log_entry_data = urlparse(resp["log_entry_url"])
+ # pass
+
+ def test_log_entry_values_success(self):
+ _id = "whatever-id"
+ params = {"barcode": self.picking.name}
+ kw = {"result": {"data": "worked!"}}
+ # test full data request only once, other tests will skip this part
+ httprequest = {"url": "https://my.odoo.test/service/endpoint", "method": "POST"}
+ extra_headers = {"KEEP-ME": "FOO"}
+ with self._get_mocked_request(
+ httprequest=httprequest, extra_headers=extra_headers
+ ) as mocked_request:
+ entry = self.service._log_call_in_db(
+ self.env, mocked_request, _id, params, **kw
+ )
+ expected = {
+ "request_url": httprequest["url"],
+ "request_method": httprequest["method"],
+ "params": str(dict(params, _id=_id)),
+ "headers": str(
+ {"Cookie": "", "Api-Key": "", "KEEP-ME": "FOO"}
+ ),
+ "state": "success",
+ "result": str({"data": "worked!"}),
+ "error": False,
+ "exception_name": False,
+ "severity": False,
+ }
+ self.assertRecordValues(entry, [expected])
+
+ def test_log_entry_values_failed(self):
+ _id = "whatever-id"
+ params = {"barcode": self.picking.name}
+ # no result, will fail
+ kw = {"result": {}}
+ with self._get_mocked_request() as mocked_request:
+ entry = self.service._log_call_in_db(
+ self.env, mocked_request, _id, params, **kw
+ )
+ expected = {
+ "state": "failed",
+ "result": str({}),
+ "error": False,
+ "exception_name": False,
+ "severity": False,
+ }
+ self.assertRecordValues(entry, [expected])
+
+ def _test_log_entry_values_failed_with_exception_default(self, severity=None):
+ _id = "whatever-id"
+ params = {"barcode": self.picking.name}
+ fake_tb = """
+ [...]
+ File "/somewhere/in/your/custom/code/file.py", line 503, in write
+ [...]
+ ValueError: Ops, something went wrong
+ """
+ orig_exception = ValueError("Ops, something went wrong")
+ kw = {"result": {}, "traceback": fake_tb, "orig_exception": orig_exception}
+ with self._get_mocked_request() as mocked_request:
+ entry = self.service._log_call_in_db(
+ self.env, mocked_request, _id, params, **kw
+ )
+ expected = {
+ "state": "failed",
+ "result": str({}),
+ "error": fake_tb,
+ "exception_name": "ValueError",
+ "exception_message": "Ops, something went wrong",
+ "severity": severity or "severe",
+ }
+ self.assertRecordValues(entry, [expected])
+
+ def test_log_entry_values_failed_with_exception_default(self):
+ self._test_log_entry_values_failed_with_exception_default()
+
+ def test_log_entry_values_failed_with_exception_functional(self):
+ _id = "whatever-id"
+ params = {"barcode": self.picking.name}
+ fake_tb = """
+ [...]
+ File "/somewhere/in/your/custom/code/file.py", line 503, in write
+ [...]
+ UserError: You are doing something wrong Dave!
+ """
+ orig_exception = exceptions.UserError("You are doing something wrong Dave!")
+ kw = {"result": {}, "traceback": fake_tb, "orig_exception": orig_exception}
+ with self._get_mocked_request() as mocked_request:
+ entry = self.service._log_call_in_db(
+ self.env, mocked_request, _id, params, **kw
+ )
+ expected = {
+ "state": "failed",
+ "result": str({}),
+ "error": fake_tb,
+ "exception_name": "odoo.exceptions.UserError",
+ "exception_message": "You are doing something wrong Dave!",
+ "severity": "functional",
+ }
+ self.assertRecordValues(entry, [expected])
+
+ # test that we can still change severity as we like
+ entry.severity = "severe"
+ self.assertEqual(entry.severity, "severe")
+
+ def test_log_entry_severity_mapping_param(self):
+ # test override of mapping via config param
+ mapping = self.log_model._get_exception_severity_mapping()
+ self.assertEqual(mapping, self.log_model.EXCEPTION_SEVERITY_MAPPING)
+ self.assertEqual(mapping["ValueError"], "severe")
+ self.assertEqual(mapping["odoo.exceptions.UserError"], "functional")
+ value = "ValueError: warning, odoo.exceptions.UserError: severe"
+ self.env["ir.config_parameter"].sudo().create(
+ {"key": "shopfloor.log.severity.exception.mapping", "value": value}
+ )
+ mapping = self.log_model._get_exception_severity_mapping()
+ self.assertEqual(mapping["ValueError"], "warning")
+ self.assertEqual(mapping["odoo.exceptions.UserError"], "severe")
+ self._test_log_entry_values_failed_with_exception_default("warning")
+
+ def test_log_entry_severity_mapping_param_bad_values(self):
+ # bad values are discarded
+ value = """
+ ValueError: warning,
+ odoo.exceptions.UserError::badvalue,
+ VeryBadValue|error
+ """
+ self.env["ir.config_parameter"].sudo().create(
+ {"key": "shopfloor.log.severity.exception.mapping", "value": value}
+ )
+ mapping = self.log_model._get_exception_severity_mapping()
+ expected = self.log_model.EXCEPTION_SEVERITY_MAPPING.copy()
+ expected["ValueError"] = "warning"
+ self.assertEqual(mapping, expected)
diff --git a/shopfloor/views/shopfloor_log_views.xml b/shopfloor/views/shopfloor_log_views.xml
index 6ebb576f7c..7f250d96a3 100644
--- a/shopfloor/views/shopfloor_log_views.xml
+++ b/shopfloor/views/shopfloor_log_views.xml
@@ -10,6 +10,9 @@
+
+
+
@@ -58,7 +61,11 @@
name="error"
attrs="{'invisible': [('state', '!=', 'failed')]}"
>
-
+
+
+
+
+
Date: Wed, 14 Oct 2020 11:10:24 +0200
Subject: [PATCH 401/940] shopfloor: improve logs filtering
---
shopfloor/views/shopfloor_log_views.xml | 41 +++++++++++++++++++++++++
1 file changed, 41 insertions(+)
diff --git a/shopfloor/views/shopfloor_log_views.xml b/shopfloor/views/shopfloor_log_views.xml
index 7f250d96a3..5e4ed99f53 100644
--- a/shopfloor/views/shopfloor_log_views.xml
+++ b/shopfloor/views/shopfloor_log_views.xml
@@ -87,12 +87,35 @@
+
+
+
+
+
+
+
+
+
+
Date: Wed, 14 Oct 2020 13:48:01 +0200
Subject: [PATCH 402/940] shopfloor: log dict data as json
---
shopfloor/services/service.py | 7 ++++---
shopfloor/tests/test_db_logging.py | 13 +++++++------
2 files changed, 11 insertions(+), 9 deletions(-)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 791b567d4d..7c11e20aac 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -1,6 +1,7 @@
# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# Copyright 2020 Akretion (http://www.akretion.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+import json
import traceback
from werkzeug.urls import url_encode, url_join
@@ -152,9 +153,9 @@ def _log_call_in_db_values(self, _request, _id, params, **kw):
return {
"request_url": httprequest.url,
"request_method": httprequest.method,
- "params": params,
- "headers": headers,
- "result": result,
+ "params": json.dumps(params),
+ "headers": json.dumps(headers),
+ "result": json.dumps(result),
"error": error,
"exception_name": exception_name,
"exception_message": exception_message,
diff --git a/shopfloor/tests/test_db_logging.py b/shopfloor/tests/test_db_logging.py
index f6a4a8ed48..6795327292 100644
--- a/shopfloor/tests/test_db_logging.py
+++ b/shopfloor/tests/test_db_logging.py
@@ -2,6 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
# from urllib.parse import urlparse
# import mock
+import json
from odoo import exceptions
@@ -100,12 +101,12 @@ def test_log_entry_values_success(self):
expected = {
"request_url": httprequest["url"],
"request_method": httprequest["method"],
- "params": str(dict(params, _id=_id)),
- "headers": str(
+ "params": json.dumps(dict(params, _id=_id)),
+ "headers": json.dumps(
{"Cookie": "", "Api-Key": "", "KEEP-ME": "FOO"}
),
"state": "success",
- "result": str({"data": "worked!"}),
+ "result": json.dumps({"data": "worked!"}),
"error": False,
"exception_name": False,
"severity": False,
@@ -123,7 +124,7 @@ def test_log_entry_values_failed(self):
)
expected = {
"state": "failed",
- "result": str({}),
+ "result": "{}",
"error": False,
"exception_name": False,
"severity": False,
@@ -147,7 +148,7 @@ def _test_log_entry_values_failed_with_exception_default(self, severity=None):
)
expected = {
"state": "failed",
- "result": str({}),
+ "result": "{}",
"error": fake_tb,
"exception_name": "ValueError",
"exception_message": "Ops, something went wrong",
@@ -175,7 +176,7 @@ def test_log_entry_values_failed_with_exception_functional(self):
)
expected = {
"state": "failed",
- "result": str({}),
+ "result": "{}",
"error": fake_tb,
"exception_name": "odoo.exceptions.UserError",
"exception_message": "You are doing something wrong Dave!",
From 262342d5fd981414ef70b9b8f6d45718a846311f Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 14 Oct 2020 13:49:18 +0200
Subject: [PATCH 403/940] shopfloor: log improve form display
---
shopfloor/views/shopfloor_log_views.xml | 18 ++++++------------
1 file changed, 6 insertions(+), 12 deletions(-)
diff --git a/shopfloor/views/shopfloor_log_views.xml b/shopfloor/views/shopfloor_log_views.xml
index 5e4ed99f53..b2dee1d9f9 100644
--- a/shopfloor/views/shopfloor_log_views.xml
+++ b/shopfloor/views/shopfloor_log_views.xml
@@ -36,12 +36,8 @@
-
-
-
-
-
-
+
+
-
+
+
From 16b5ade74c96ba2522c7092912246904bd0bffa4 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 14 Oct 2020 13:50:22 +0200
Subject: [PATCH 404/940] shopfloor: bump 13.0.1.2.0 with migration step
---
shopfloor/__manifest__.py | 2 +-
.../migrations/13.0.1.2.0/post-migration.py | 62 +++++++++++++++++++
shopfloor/models/shopfloor_log.py | 1 +
shopfloor/services/service.py | 6 +-
4 files changed, 67 insertions(+), 4 deletions(-)
create mode 100644 shopfloor/migrations/13.0.1.2.0/post-migration.py
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 6a90cb2e79..f7d6b4a123 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.1.0",
+ "version": "13.0.1.2.0",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
diff --git a/shopfloor/migrations/13.0.1.2.0/post-migration.py b/shopfloor/migrations/13.0.1.2.0/post-migration.py
new file mode 100644
index 0000000000..be81c55e98
--- /dev/null
+++ b/shopfloor/migrations/13.0.1.2.0/post-migration.py
@@ -0,0 +1,62 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+import json
+import logging
+
+from odoo import SUPERUSER_ID, api
+
+_logger = logging.getLogger("shopfloor." + __name__)
+
+
+def _compute_logs_new_values(env):
+ log_entries = env["shopfloor.log"].search([])
+ for entry in log_entries:
+ new_vals = {}
+ for fname in ("params", "headers", "result"):
+ if not entry[fname]:
+ continue
+ # make it json-like
+ replace_map = [
+ ("{'", '{"'),
+ ("'}", '"}'),
+ ("':", '":'),
+ (": '", ': "'),
+ ("',", '",'),
+ (", '", ', "'),
+ ("False", "false"),
+ ("True", "true"),
+ ("None", "null"),
+ ("\\xa0", " "),
+ ]
+ json_val = entry[fname]
+ for to_replace, replace_with in replace_map:
+ json_val = json_val.replace(to_replace, replace_with)
+ try:
+ val = json.loads(json_val)
+ except Exception:
+ # fail gracefully and do not break the whole thing
+ # just for not being able to convert a value.
+ # We don't use these values as json yet, no harm.
+ _logger.warning(
+ "`%s` JSON convert failed for record %d", (fname, entry.id)
+ )
+ else:
+ new_vals[fname] = json.dumps(val, indent=4, sort_keys=True)
+ if entry.error and not entry.exception_name:
+ new_vals.update(_get_exception_details(entry))
+ entry.write(new_vals)
+
+
+def _get_exception_details(entry):
+ for line in reversed(entry.error.splitlines()):
+ if "Error:" in line:
+ name, msg = line.split(":", 1)
+ return {
+ "exception_name": name.strip(),
+ "exception_message": msg.strip("() "),
+ }
+
+
+def migrate(cr, version):
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ _compute_logs_new_values(env)
diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py
index 9e04a0f1ce..62bb26519b 100644
--- a/shopfloor/models/shopfloor_log.py
+++ b/shopfloor/models/shopfloor_log.py
@@ -26,6 +26,7 @@ class ShopfloorLog(models.Model):
request_url = fields.Char(readonly=True, string="Request URL")
request_method = fields.Char(readonly=True)
params = fields.Text(readonly=True)
+ # TODO: make these fields serialized and use a computed field for displaying
headers = fields.Text(readonly=True)
result = fields.Text(readonly=True)
error = fields.Text(readonly=True)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 7c11e20aac..7b0cac866c 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -153,9 +153,9 @@ def _log_call_in_db_values(self, _request, _id, params, **kw):
return {
"request_url": httprequest.url,
"request_method": httprequest.method,
- "params": json.dumps(params),
- "headers": json.dumps(headers),
- "result": json.dumps(result),
+ "params": json.dumps(params, indent=4, sort_keys=True),
+ "headers": json.dumps(headers, indent=4, sort_keys=True),
+ "result": json.dumps(result, indent=4, sort_keys=True),
"error": error,
"exception_name": exception_name,
"exception_message": exception_message,
From 25fd5ba001153c9038f6713c7b9a3c50c5c12267 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 19 Oct 2020 10:08:43 +0200
Subject: [PATCH 405/940] shopfloor: log use ace widget for params n headers
---
shopfloor/views/shopfloor_log_views.xml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/shopfloor/views/shopfloor_log_views.xml b/shopfloor/views/shopfloor_log_views.xml
index b2dee1d9f9..603a185c3b 100644
--- a/shopfloor/views/shopfloor_log_views.xml
+++ b/shopfloor/views/shopfloor_log_views.xml
@@ -36,8 +36,8 @@
-
-
+
+
Date: Wed, 21 Oct 2020 11:32:26 +0200
Subject: [PATCH 406/940] location content transfer: add test to reproduce an
error
The following error is raised if there is no move line of another
picking type to unreserve:
ValueError: not enough values to unpack (expected 3, got 0)
---
.../tests/test_location_content_transfer_start.py | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py
index 20c3e7da07..29d0470d16 100644
--- a/shopfloor/tests/test_location_content_transfer_start.py
+++ b/shopfloor/tests/test_location_content_transfer_start.py
@@ -233,6 +233,20 @@ def test_scan_location_wrong_picking_type_allow_unreserve_ok(self):
],
)
+ def test_scan_location_wrong_picking_type_allow_unreserve_empty(self):
+ """Content has different picking type than menu, option to unreserve
+
+ There is no move line of another picking type to unreserve.
+ """
+ self.menu.sudo().allow_unreserve_other_moves = True
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.content_loc.barcode}
+ )
+ self.assert_response_start(
+ response,
+ message=self.service.msg_store.no_pack_in_location(self.content_loc),
+ )
+
def test_scan_location_wrong_picking_type_allow_unreserve_error(self):
"""Content has different picking type than menu, option to unreserve
From 28e32ec72512e9c1554aab1688a367824ce11da5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Wed, 21 Oct 2020 11:35:27 +0200
Subject: [PATCH 407/940] location content transfer: fix if no move line to
unreserve
---
shopfloor/services/location_content_transfer.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 4e296d82a8..3ed93c4590 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -234,7 +234,7 @@ def _unreserve_other_lines(self, location, move_lines):
lambda line: line.picking_id.picking_type_id not in self.picking_types
)
if not lines_other_picking_types:
- return move_lines
+ return (move_lines, None, None)
unreserved_moves = move_lines.move_id
location_move_lines = self.env["stock.move.line"].search(
self._find_location_all_move_lines_domain(location)
From a20f949ebe58efbfa5e4c6b3c84128a2b9097316 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 29 Sep 2020 11:20:10 +0200
Subject: [PATCH 408/940] zone_picking: list all zones on start
When the operator starts with zone picking scenario
the 1st screen presents the list of zones
that have lines to process.
---
shopfloor/services/zone_picking.py | 137 +++++++++++++++------
shopfloor/tests/test_zone_picking_base.py | 14 ++-
shopfloor/tests/test_zone_picking_start.py | 65 +++++++++-
3 files changed, 178 insertions(+), 38 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index ae09596710..e61992cf2b 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1,6 +1,7 @@
# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import functools
+from itertools import groupby
from odoo.fields import first
from odoo.tools.float_utils import float_compare, float_is_zero
@@ -82,7 +83,14 @@ class ZonePicking(Component):
_description = __doc__
def _response_for_start(self, message=None):
- return self._response(next_state="start", message=message)
+ zones = self.work.menu.picking_type_ids.mapped(
+ "default_location_src_id.child_ids"
+ )
+ return self._response(
+ next_state="start",
+ data={"zones": self._data_for_select_zone(zones)},
+ message=message,
+ )
def _response_for_select_picking_type(
self, zone_location, picking_types, message=None
@@ -189,30 +197,24 @@ def _data_for_select_picking_type(self, zone_location, picking_types):
for datum in data["picking_types"]:
picking_type = self.env["stock.picking.type"].browse(datum["id"])
zone_lines = self._picking_type_zone_lines(zone_location, picking_type)
- priority_lines = zone_lines.filtered(
- lambda line: line.picking_id.priority in ["2", "3"]
- )
-
- datum.update(
- {
- "lines_count": len(zone_lines),
- "picking_count": len(zone_lines.mapped("picking_id")),
- "priority_lines_count": len(priority_lines),
- "priority_picking_count": len(priority_lines.mapped("picking_id")),
- }
- )
+ datum.update(self._counters_for_zone_lines(zone_lines))
return data
+ def _counters_for_zone_lines(self, zone_lines):
+ # Not using mapped/filtered to support simple lists and generators
+ priority_lines = [x for x in zone_lines if x.picking_id.priority in ("2", "3")]
+ return {
+ "lines_count": len(zone_lines),
+ "picking_count": len({x.picking_id.id for x in zone_lines}),
+ "priority_lines_count": len(priority_lines),
+ "priority_picking_count": len({x.picking_id.id for x in priority_lines}),
+ }
+
def _picking_type_zone_lines(self, zone_location, picking_type):
- return self.env["stock.move.line"].search(
- [
- ("location_id", "child_of", zone_location.id),
- # we have auto_join on picking_id
- ("picking_id.picking_type_id", "=", picking_type.id),
- ("qty_done", "=", 0),
- ("state", "in", ("assigned", "partially_available")),
- ]
+ domain = self._find_location_move_lines_domain(
+ zone_location, picking_type=picking_type
)
+ return self.env["stock.move.line"].search(domain)
def _data_for_move_line(self, zone_location, picking_type, move_line):
return {
@@ -246,15 +248,49 @@ def _data_for_location(self, zone_location, picking_type, location):
"location": self.data.location(location),
}
+ def _zone_lines(self, zones):
+ return self.env["stock.move.line"].search(
+ self._find_location_move_lines_domain(zones)
+ )
+
+ def _data_for_select_zone(self, zones):
+ """Retrieve detailed info for each zone.
+
+ Zone without lines are skipped.
+ Zone with lines will have line counters by operation type.
+
+ :param zones: zone location recordset
+ :return: see _schema_for_select_zone
+ """
+ res = []
+ for zone in zones:
+ zone_data = self.data.location(zone)
+ zone_lines = self._zone_lines(zone).sorted(
+ key=lambda x: x.picking_id.picking_type_id
+ )
+ if not zone_lines:
+ continue
+ zone_data["operation_types"] = []
+
+ for picking_type, lines in groupby(
+ zone_lines, lambda line: line.picking_id.picking_type_id
+ ):
+ op_type = self.data.picking_type(picking_type)
+ op_type.update(self._counters_for_zone_lines(list(lines)))
+ zone_data["operation_types"].append(op_type)
+ res.append(zone_data)
+ return res
+
def _find_location_move_lines_domain(
- self, location, picking_type=None, package=None, product=None, lot=None
+ self, locations, picking_type=None, package=None, product=None, lot=None
):
domain = [
- ("location_id", "child_of", location.id),
+ ("location_id", "child_of", locations.ids),
("qty_done", "=", 0),
("state", "in", ("assigned", "partially_available")),
]
if picking_type:
+ # auto_join in place for this field
domain += [("picking_id.picking_type_id", "=", picking_type.id)]
else:
domain += [("picking_id.picking_type_id", "in", self.picking_types.ids)]
@@ -268,17 +304,17 @@ def _find_location_move_lines_domain(
def _find_location_move_lines(
self,
- location,
+ locations,
picking_type=None,
package=None,
product=None,
lot=None,
order="priority",
):
- """Find lines that potentially are to move in the location"""
+ """Find lines that potentially need work in given locations."""
move_lines = self.env["stock.move.line"].search(
self._find_location_move_lines_domain(
- location, picking_type, package, product, lot
+ locations, picking_type, package, product, lot
)
)
sort_keys_func, reverse = self._sort_key_move_lines(order)
@@ -329,6 +365,16 @@ def _group_buffer_move_lines_by_package(self, move_lines):
data[move_line.result_package_id] |= move_line
return data
+ def select_zone(self):
+ """Retrieve all available zones to work with.
+
+ A zone is defined by the first level location below the source location
+ of the operation types linked to the menu.
+
+ The count of lines to process by available operations is computed per each zone.
+ """
+ return self._response_for_start()
+
def scan_location(self, barcode):
"""Scan the zone location where the picking should occur
@@ -1329,6 +1375,9 @@ class ShopfloorZonePickingValidator(Component):
_name = "shopfloor.zone_picking.validator"
_usage = "zone_picking.validator"
+ def select_zone(self):
+ return {}
+
def scan_location(self):
return {"barcode": {"required": True, "type": "string"}}
@@ -1446,7 +1495,7 @@ def _states(self):
to the next state.
"""
return {
- "start": {},
+ "start": self._schema_for_select_zone,
"select_picking_type": self._schema_for_select_picking_type,
"select_line": self._schema_for_move_lines_empty_location,
"set_line_destination": self._schema_for_move_line,
@@ -1457,6 +1506,9 @@ def _states(self):
"unload_set_destination": self._schema_for_move_line,
}
+ def select_zone(self):
+ return self._response_schema(next_states={"start"})
+
def scan_location(self):
return self._response_schema(next_states={"start", "select_picking_type"})
@@ -1519,17 +1571,32 @@ def unload_set_destination(self):
next_states={"unload_single", "unload_set_destination", "select_line"}
)
+ @property
+ def _schema_for_select_zone(self):
+ zone_schema = self.schemas.location()
+ picking_type_schema = self.schemas.picking_type()
+ picking_type_schema.update(self._schema_for_zone_line_counters)
+ zone_schema["operation_types"] = self.schemas._schema_list_of(
+ picking_type_schema
+ )
+ zone_schema = {
+ "zones": self.schemas._schema_list_of(zone_schema),
+ }
+ return zone_schema
+
+ @property
+ def _schema_for_zone_line_counters(self):
+ return {
+ "lines_count": {"type": "float", "required": True},
+ "picking_count": {"type": "float", "required": True},
+ "priority_lines_count": {"type": "float", "required": True},
+ "priority_picking_count": {"type": "float", "required": True},
+ }
+
@property
def _schema_for_select_picking_type(self):
picking_type = self.schemas.picking_type()
- picking_type.update(
- {
- "lines_count": {"type": "float", "required": True},
- "picking_count": {"type": "float", "required": True},
- "priority_lines_count": {"type": "float", "required": True},
- "priority_picking_count": {"type": "float", "required": True},
- }
- )
+ picking_type.update(self._schema_for_zone_line_counters)
schema = {
"zone_location": self.schemas._schema_dict_of(self.schemas.location()),
"picking_types": self.schemas._schema_list_of(picking_type),
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 95d59c2b8e..e6fdd4ec0c 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -29,6 +29,8 @@ def setUpClassBaseData(cls, *args, **kwargs):
}
)
)
+ # Set default location for our picking type
+ cls.menu.picking_type_ids[0].sudo().default_location_src_id = cls.zone_location
cls.zone_sublocation1 = (
cls.env["stock.location"]
.sudo()
@@ -186,8 +188,16 @@ def setUp(self):
with self.work_on_services(menu=self.menu, profile=self.profile) as work:
self.service = work.component(usage="zone_picking")
- def assert_response_start(self, response, message=None):
- self.assert_response(response, next_state="start", message=message)
+ def _assert_response_select_zone(self, response, zone_locations, message=None):
+ data = {"zones": self.service._data_for_select_zone(zone_locations)}
+ self.assert_response(
+ response, next_state="start", data=data, message=message,
+ )
+
+ def assert_response_start(self, response, zone_locations=None, message=None):
+ if zone_locations is None:
+ zone_locations = self.zone_location.child_ids
+ self._assert_response_select_zone(response, zone_locations, message=message)
def _assert_response_select_picking_type(
self, state, response, zone_location, picking_types, message=None
diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py
index 0f7d2a61d3..2e1ecdc242 100644
--- a/shopfloor/tests/test_zone_picking_start.py
+++ b/shopfloor/tests/test_zone_picking_start.py
@@ -32,6 +32,66 @@ def setUpClassBaseData(cls, *args, **kwargs):
cls._update_qty_in_location(cls.zone_sublocation1, cls.product_b, 10)
extra_picking.action_assign()
+ def test_data_for_zone(self):
+ op_type_data = self.data.picking_type(self.menu.picking_type_ids[0])
+ zones_data = self.service._response_for_start()["data"]["start"]["zones"]
+ expected_sub1 = dict(
+ self.data.location(self.zone_sublocation1),
+ operation_types=[
+ dict(
+ op_type_data,
+ lines_count=1,
+ picking_count=1,
+ priority_lines_count=0,
+ priority_picking_count=0,
+ )
+ ],
+ )
+ expected_sub2 = dict(
+ self.data.location(self.zone_sublocation2),
+ operation_types=[
+ dict(
+ op_type_data,
+ lines_count=2,
+ picking_count=1,
+ priority_lines_count=0,
+ priority_picking_count=0,
+ )
+ ],
+ )
+ expected_sub3 = dict(
+ self.data.location(self.zone_sublocation3),
+ operation_types=[
+ dict(
+ op_type_data,
+ lines_count=2,
+ picking_count=2,
+ priority_lines_count=0,
+ priority_picking_count=0,
+ )
+ ],
+ )
+ expected_sub4 = dict(
+ self.data.location(self.zone_sublocation4),
+ operation_types=[
+ dict(
+ op_type_data,
+ lines_count=3,
+ picking_count=2,
+ priority_lines_count=0,
+ priority_picking_count=0,
+ )
+ ],
+ )
+ self.assertEqual(
+ zones_data, [expected_sub1, expected_sub2, expected_sub3, expected_sub4]
+ )
+
+ def test_select_zone(self):
+ """Scanned location invalid, no location found."""
+ response = self.service.dispatch("select_zone")
+ self.assert_response_start(response)
+
def test_scan_location_wrong_barcode(self):
"""Scanned location invalid, no location found."""
response = self.service.dispatch(
@@ -54,8 +114,11 @@ def test_scan_location_not_allowed(self):
def test_scan_location_no_move_lines(self):
"""Scanned location valid, but no move lines found in it."""
+ sub1_lines = self.service._find_location_move_lines(self.zone_sublocation1)
+ # no more lines available
+ sub1_lines.picking_id.action_cancel()
response = self.service.dispatch(
- "scan_location", params={"barcode": self.shelf2.barcode},
+ "scan_location", params={"barcode": self.zone_sublocation1.barcode},
)
self.assert_response_start(
response, message=self.service.msg_store.no_lines_to_process(),
From f2dcb0cfa1911f1bb6610394489e4d1e45591559 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 26 Oct 2020 08:17:11 +0100
Subject: [PATCH 409/940] Fix error with message being a dict, not str
Traceback (most recent call last):
File "/odoo/external-src/wms-shopfloor/setup/shopfloor/odoo/addons/shopfloor/services/service.py", line 69, in _dispatch_with_db_logging result = super().dispatch(method_name, _id=_id, params=params)
File "/odoo/external-src/rest-framework/setup/base_rest/odoo/addons/base_rest/components/service.py", line 199, in dispatch res = func(**secure_params)
File "/odoo/external-src/wms-shopfloor/setup/shopfloor/odoo/addons/shopfloor/services/delivery.py", line 123, in scan_deliver return self._deliver_package(picking, package)
File "/odoo/external-src/wms-shopfloor/setup/shopfloor/odoo/addons/shopfloor/services/delivery.py", line 170, in _deliver_package message, TypeError: sequence item 1: expected str instance, dict found
The result of _check_picking_status is the full dict for a message
(message_type, body), so we have to update its body.
---
shopfloor/services/delivery.py | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 74cf09bce6..476d97073a 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -164,12 +164,12 @@ def _deliver_package(self, picking, package):
# State of the picking might change while we reach this point: check again!
message = self._check_picking_status(lines.mapped("picking_id"))
if message:
- message = "\n".join(
+ message["body"] = "\n".join(
[
_("Package {} belongs to a picking without a valid state.").format(
package.name
),
- message,
+ message["body"],
]
)
return self._response_for_deliver(message=message)
@@ -228,12 +228,12 @@ def _deliver_product(self, picking, product):
# State of the picking might change while we reach this point: check again!
message = self._check_picking_status(lines.mapped("picking_id"))
if message:
- message = "\n".join(
+ message["body"] = "\n".join(
[
_("Product {} belongs to a picking without a valid state.").format(
product.name
),
- message,
+ message["body"],
]
)
return self._response_for_deliver(message=message)
@@ -277,12 +277,12 @@ def _deliver_lot(self, picking, lot):
# State of the picking might change while we reach this point: check again!
message = self._check_picking_status(lines.mapped("picking_id"))
if message:
- message = "\n".join(
+ message["body"] = "\n".join(
[
_("Lot {} belongs to a picking without a valid state.").format(
lot.name
),
- message,
+ message["body"],
]
)
return self._response_for_deliver(message=message)
From 668fa0d080dbfbd62f3130b716fb36434be4ff0f Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 26 Oct 2020 10:32:13 +0100
Subject: [PATCH 410/940] location content transfer: fix wrong return
The caller expects a recordset of moves in the second item of the tuple.
---
shopfloor/services/location_content_transfer.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 3ed93c4590..35f8395fb4 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -234,7 +234,7 @@ def _unreserve_other_lines(self, location, move_lines):
lambda line: line.picking_id.picking_type_id not in self.picking_types
)
if not lines_other_picking_types:
- return (move_lines, None, None)
+ return (move_lines, self.env["stock.move"].browse(), None)
unreserved_moves = move_lines.move_id
location_move_lines = self.env["stock.move.line"].search(
self._find_location_all_move_lines_domain(location)
From 2eacbcd0de0165c99131c424ccd3272fc82408ce Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 26 Oct 2020 09:10:43 +0100
Subject: [PATCH 411/940] shopfloor: refactor header validation
Headers might depend on services and we hacked this via controller.
Now the validation and the computation for their work ctx attribute
is delegated to the service. This way we can control by service
what is needed and customize validation per service if needed.
---
shopfloor/controllers/main.py | 64 -----------------------------------
shopfloor/services/service.py | 61 +++++++++++++++++++++++++++++++++
shopfloor/tests/common.py | 6 +++-
3 files changed, 66 insertions(+), 65 deletions(-)
diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py
index 86676e235d..218e08e3b7 100644
--- a/shopfloor/controllers/main.py
+++ b/shopfloor/controllers/main.py
@@ -1,75 +1,11 @@
# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# Copyright 2020 Akretion (http://www.akretion.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
-from werkzeug.exceptions import BadRequest
-
-from odoo.http import request
from odoo.addons.base_rest.controllers import main
-MENU_ID_HEADER = "HTTP_SERVICE_CTX_MENU_ID"
-# (name, model, dest_key)
-MENU_HEADER_RULE = (MENU_ID_HEADER, "shopfloor.menu", "menu")
-
-PROFILE_ID_HEADER = "HTTP_SERVICE_CTX_PROFILE_ID"
-PROFILE_HEADER_RULE = (PROFILE_ID_HEADER, "shopfloor.profile", "profile")
-
class ShopfloorController(main.RestController):
_root_path = "/shopfloor/"
_collection_name = "shopfloor.service"
_default_auth = "api_key"
- # TODO: this should come from registered services.
- # We would need to change how their ctx is initialized tho
- # because ATM the ctx is computed before lookup.
- _service_headers_rules = {
- # no special header required for config
- "app/user_config": (),
- "scan_anything/scan": (),
- # profile header is required to get menu items
- # fmt: off
- # NOTE: turn off formatting here is mandatory
- # otherwise black removes the space and flake8 w/ complain the comma
- # before parenthesis which is required to make this a tuple!
- "app/menu": (PROFILE_HEADER_RULE, ),
- # profile + menu is required to call processes
- "process": (PROFILE_HEADER_RULE, MENU_HEADER_RULE, ),
- # fmt: on
- }
-
- def _get_component_context(self):
- """
- This method adds the component context:
- * the shopfloor menu in ``self.work.menu`` from the service Components
- * the shopfloor profile in ``self.work.profile`` from the service
- Components
- """
- res = super(ShopfloorController, self)._get_component_context()
- res["menu"] = None
- res["profile"] = None
- res.update(self._get_process_context(request))
- return res
-
- # TODO: add tests
- def _get_process_context(self, request):
- ctx = {}
- env = request.env
- headers = request.httprequest.environ
- # '/shopfloor/app/user_config' -> app/config
- service_path = request.httprequest.path.split(self._root_path)[-1]
-
- # default to process rule
- default = self._service_headers_rules["process"]
- headers_map = self._service_headers_rules.get(service_path, default)
- for header_name, model, dest_key in headers_map:
- try:
- rec_id = int(headers.get(header_name))
- except (TypeError, ValueError):
- raise BadRequest("{} must be set with an integer".format(header_name))
- rec = env[model].browse(rec_id).exists()
- if not rec:
- raise BadRequest(
- "Record {} with ID = {} not found".format(model, rec_id)
- )
- ctx[dest_key] = rec
- return ctx
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 7b0cac866c..5c39cf446c 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -4,6 +4,7 @@
import json
import traceback
+from werkzeug.exceptions import BadRequest
from werkzeug.urls import url_encode, url_join
from odoo import _, exceptions, registry
@@ -56,6 +57,7 @@ class BaseShopfloorService(AbstractComponent):
_log_calls_in_db = True
def dispatch(self, method_name, _id=None, params=None):
+ self._validate_headers_update_work_context(request, method_name)
if not self._db_logging_active():
return super().dispatch(method_name, _id=_id, params=params)
return self._dispatch_with_db_logging(method_name, _id=_id, params=params)
@@ -352,6 +354,65 @@ def data_detail(self):
def msg_store(self):
return self.actions_for("message")
+ # TODO: maybe to be proposed to base_rest
+ # TODO: add tests
+ def _validate_headers_update_work_context(self, request, method_name):
+ """Validate request and update context per service.
+
+ Our services may require extra headers.
+ The service component is loaded after the ctx has been initialized
+ hence we need an hook were we can validate by component/service
+ if the request is compliant with what we need (eg: missing header)
+ """
+ if self.env.context.get("_service_skip_request_validation"):
+ return
+ extra_work_ctx = {}
+ headers = request.httprequest.environ
+ for rule, active in self._validation_rules:
+ if not active:
+ continue
+ header_name, coerce_func, ctx_value_handler_name = rule
+ try:
+ header_value = coerce_func(headers.get(header_name))
+ except (TypeError, ValueError) as err:
+ raise BadRequest(
+ "{} header validation error: {}".format(header_name, str(err))
+ )
+ ctx_value_handler = getattr(self, ctx_value_handler_name)
+ dest_key, value = ctx_value_handler(header_value)
+ if not value:
+ raise BadRequest("{} header value lookup error".format(header_name))
+ extra_work_ctx[dest_key] = value
+ for k, v in extra_work_ctx.items():
+ setattr(self.work, k, v)
+
+ @property
+ def _validation_rules(self):
+ return (
+ # rule to apply, active flag
+ (self.MENU_ID_HEADER_RULE, self._requires_header_menu),
+ (self.PROFILE_ID_HEADER_RULE, self._requires_header_profile),
+ )
+
+ MENU_ID_HEADER_RULE = (
+ # header name, coerce func, ctx handler
+ "HTTP_SERVICE_CTX_MENU_ID",
+ int,
+ "_work_ctx_get_menu_id",
+ )
+ PROFILE_ID_HEADER_RULE = (
+ # header name, coerce func, ctx value handler
+ "HTTP_SERVICE_CTX_PROFILE_ID",
+ int,
+ "_work_ctx_get_profile_id",
+ )
+
+ def _work_ctx_get_menu_id(self, rec_id):
+ return "menu", self.env["shopfloor.menu"].browse(rec_id).exists()
+
+ def _work_ctx_get_profile_id(self, rec_id):
+ return "profile", self.env["shopfloor.profile"].browse(rec_id).exists()
+
class BaseShopfloorProcess(AbstractComponent):
"""Base class for process rest service"""
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index 0eb6612b5b..e3c7a056f6 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -82,7 +82,11 @@ def setUp(self):
def setUpClass(cls):
super(CommonCase, cls).setUpClass()
cls.env = cls.env(
- context=dict(cls.env.context, tracking_disable=cls.tracking_disable)
+ context=dict(
+ cls.env.context,
+ tracking_disable=cls.tracking_disable,
+ _service_skip_request_validation=True,
+ )
)
cls.setUpComponent()
From 9b021c15b8ed057c6c08c4ef335d5e6a9ab8a11a Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 26 Oct 2020 09:14:33 +0100
Subject: [PATCH 412/940] shopfloor: split app/menu to user/menu
---
shopfloor/README.rst | 2 +-
shopfloor/readme/USAGE.rst | 2 +-
shopfloor/services/__init__.py | 1 +
shopfloor/services/app.py | 28 +----------
shopfloor/services/user.py | 52 ++++++++++++++++++++
shopfloor/static/description/index.html | 2 +-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_app.py | 49 -------------------
shopfloor/tests/test_user.py | 65 +++++++++++++++++++++++++
9 files changed, 124 insertions(+), 78 deletions(-)
create mode 100644 shopfloor/services/user.py
create mode 100644 shopfloor/tests/test_user.py
diff --git a/shopfloor/README.rst b/shopfloor/README.rst
index 12590ac995..db94b7f8d3 100644
--- a/shopfloor/README.rst
+++ b/shopfloor/README.rst
@@ -100,7 +100,7 @@ the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DA
Curl example::
- curl -X POST "http://localhost:8069/shopfloor/app/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC"
+ curl -X POST "http://localhost:8069/shopfloor/user/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC"
Known issues / Roadmap
======================
diff --git a/shopfloor/readme/USAGE.rst b/shopfloor/readme/USAGE.rst
index d628404b12..9f4832dd09 100644
--- a/shopfloor/readme/USAGE.rst
+++ b/shopfloor/readme/USAGE.rst
@@ -3,4 +3,4 @@ the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DA
Curl example::
- curl -X POST "http://localhost:8069/shopfloor/app/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC"
+ curl -X POST "http://localhost:8069/shopfloor/user/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC"
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index 166173654b..b3d8962f25 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -6,6 +6,7 @@
# generic services
from . import app
+from . import user
from . import menu
from . import profile
from . import scan_anything
diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py
index d0d2be4704..26edc2cfb0 100644
--- a/shopfloor/services/app.py
+++ b/shopfloor/services/app.py
@@ -10,20 +10,14 @@ class ShopfloorApp(Component):
_name = "shopfloor.app"
_usage = "app"
_description = __doc__
- # TODO this is required only for `menu` and not for `user_config`
- # Maybe we should split them.
- _requires_header_profile = True
+ # TODO: maybe rename to `config` or `app_config`
+ # as this is not related to current user conf
def user_config(self):
profiles_comp = self.component("profile")
profiles = profiles_comp._to_json(profiles_comp._search())
return self._response(data={"profiles": profiles})
- def menu(self):
- menu_comp = self.component("menu")
- menus = menu_comp._to_json(menu_comp._search())
- return self._response(data={"menus": menus})
-
class ShopfloorAppValidator(Component):
"""Validators for the Application endpoints"""
@@ -35,9 +29,6 @@ class ShopfloorAppValidator(Component):
def user_config(self):
return {}
- def menu(self):
- return {}
-
class ShopfloorAppValidatorResponse(Component):
"""Validators for the Application endpoints responses"""
@@ -60,18 +51,3 @@ def user_config(self):
},
}
)
-
- def menu(self):
- menu_return_validator = self.component("menu.validator.response")
- return self._response_schema(
- {
- "menus": {
- "type": "list",
- "required": True,
- "schema": {
- "type": "dict",
- "schema": menu_return_validator._record_schema,
- },
- },
- }
- )
diff --git a/shopfloor/services/user.py b/shopfloor/services/user.py
new file mode 100644
index 0000000000..634264dddb
--- /dev/null
+++ b/shopfloor/services/user.py
@@ -0,0 +1,52 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo.addons.component.core import Component
+
+
+class ShopfloorUser(Component):
+ """Generic endpoints for user specific info."""
+
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.user"
+ _usage = "user"
+ _description = __doc__
+ _requires_header_profile = True
+
+ def menu(self):
+ menu_comp = self.component("menu")
+ menus = menu_comp._to_json(menu_comp._search())
+ return self._response(data={"menus": menus})
+
+
+class ShopfloorUserValidator(Component):
+ """Validators for the User endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.user.validator"
+ _usage = "user.validator"
+
+ def menu(self):
+ return {}
+
+
+class ShopfloorUserValidatorResponse(Component):
+ """Validators for the User endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.user.validator.response"
+ _usage = "user.validator.response"
+
+ def menu(self):
+ menu_return_validator = self.component("menu.validator.response")
+ return self._response_schema(
+ {
+ "menus": {
+ "type": "list",
+ "required": True,
+ "schema": {
+ "type": "dict",
+ "schema": menu_return_validator._record_schema,
+ },
+ },
+ }
+ )
diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html
index 62e8f7eade..cc20fd6ecc 100644
--- a/shopfloor/static/description/index.html
+++ b/shopfloor/static/description/index.html
@@ -450,7 +450,7 @@
the Demo user. The key to use in the HTTP header API-KEY is: 72B044F7AC780DAC
Curl example:
-curl -X POST "http://localhost:8069/shopfloor/app/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC"
+curl -X POST "http://localhost:8069/shopfloor/user/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC"
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 575ef7fba1..3a8e6d7fdb 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -1,4 +1,5 @@
from . import test_app
+from . import test_user
from . import test_menu
from . import test_openapi
from . import test_profile
diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py
index 11088781ad..f9b4f514be 100644
--- a/shopfloor/tests/test_app.py
+++ b/shopfloor/tests/test_app.py
@@ -36,52 +36,3 @@ def test_user_config(self):
],
},
)
-
- def test_menu_no_profile(self):
- """Request /app/menu"""
- # Simulate the client asking the menu
- response = self.service.dispatch("menu")
- menus = self.env["shopfloor.menu"].search([])
- self.assert_response(
- response,
- data={
- "menus": [
- {
- "id": menu.id,
- "name": menu.name,
- "scenario": menu.scenario,
- "picking_types": [
- {"id": picking_type.id, "name": picking_type.name}
- for picking_type in menu.picking_type_ids
- ],
- }
- for menu in menus
- ]
- },
- )
-
- def test_menu_by_profile(self):
- """Request /app/menu w/ a specific profile"""
- # Simulate the client asking the menu
- menus = self.env["shopfloor.menu"].sudo().search([])
- menu = menus[0]
- menu.profile_ids = self.profile
- (menus - menu).profile_ids = self.profile2
-
- response = self.service.dispatch("menu")
- self.assert_response(
- response,
- data={
- "menus": [
- {
- "id": menu.id,
- "name": menu.name,
- "scenario": menu.scenario,
- "picking_types": [
- {"id": picking_type.id, "name": picking_type.name}
- for picking_type in menu.picking_type_ids
- ],
- }
- ]
- },
- )
diff --git a/shopfloor/tests/test_user.py b/shopfloor/tests/test_user.py
new file mode 100644
index 0000000000..066a3f2b3b
--- /dev/null
+++ b/shopfloor/tests/test_user.py
@@ -0,0 +1,65 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from .common import CommonCase
+
+
+class UserCase(CommonCase):
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_hb_truck_demo")
+ cls.profile2 = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(profile=self.profile) as work:
+ self.service = work.component(usage="user")
+
+ def test_menu_no_profile(self):
+ """Request /user/menu"""
+ # Simulate the client asking the menu
+ response = self.service.dispatch("menu")
+ menus = self.env["shopfloor.menu"].search([])
+ self.assert_response(
+ response,
+ data={
+ "menus": [
+ {
+ "id": menu.id,
+ "name": menu.name,
+ "scenario": menu.scenario,
+ "picking_types": [
+ {"id": picking_type.id, "name": picking_type.name}
+ for picking_type in menu.picking_type_ids
+ ],
+ }
+ for menu in menus
+ ]
+ },
+ )
+
+ def test_menu_by_profile(self):
+ """Request /user/menu w/ a specific profile"""
+ # Simulate the client asking the menu
+ menus = self.env["shopfloor.menu"].sudo().search([])
+ menu = menus[0]
+ menu.profile_ids = self.profile
+ (menus - menu).profile_ids = self.profile2
+
+ response = self.service.dispatch("menu")
+ self.assert_response(
+ response,
+ data={
+ "menus": [
+ {
+ "id": menu.id,
+ "name": menu.name,
+ "scenario": menu.scenario,
+ "picking_types": [
+ {"id": picking_type.id, "name": picking_type.name}
+ for picking_type in menu.picking_type_ids
+ ],
+ }
+ ]
+ },
+ )
From 0198adc3d1a857b8fe2780ac6e17d42d4e1ae304 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 23 Oct 2020 10:03:58 +0200
Subject: [PATCH 413/940] shopfloor: add picking edit form
---
shopfloor/services/__init__.py | 3 +
shopfloor/services/forms/__init__.py | 2 +
shopfloor/services/forms/form_mixin.py | 85 ++++++++++++++++++++++++
shopfloor/services/forms/picking_form.py | 77 +++++++++++++++++++++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_picking_form.py | 61 +++++++++++++++++
6 files changed, 229 insertions(+)
create mode 100644 shopfloor/services/forms/__init__.py
create mode 100644 shopfloor/services/forms/form_mixin.py
create mode 100644 shopfloor/services/forms/picking_form.py
create mode 100644 shopfloor/tests/test_picking_form.py
diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py
index b3d8962f25..7fe2f664ea 100644
--- a/shopfloor/services/__init__.py
+++ b/shopfloor/services/__init__.py
@@ -18,3 +18,6 @@
from . import delivery
from . import location_content_transfer
from . import single_pack_transfer
+
+# forms
+from . import forms
diff --git a/shopfloor/services/forms/__init__.py b/shopfloor/services/forms/__init__.py
new file mode 100644
index 0000000000..8d328d097a
--- /dev/null
+++ b/shopfloor/services/forms/__init__.py
@@ -0,0 +1,2 @@
+from . import form_mixin
+from . import picking_form
diff --git a/shopfloor/services/forms/form_mixin.py b/shopfloor/services/forms/form_mixin.py
new file mode 100644
index 0000000000..1389082c80
--- /dev/null
+++ b/shopfloor/services/forms/form_mixin.py
@@ -0,0 +1,85 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# @author Simone Orsi
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo import _
+
+from odoo.addons.component.core import AbstractComponent
+
+
+class ShopfloorFormMixin(AbstractComponent):
+ """Allow to edit records.
+ """
+
+ _inherit = "base.shopfloor.service"
+ _name = "shopfloor.form.mixin"
+ _usage = "form_mixin"
+ _description = __doc__
+ _expose_model = ""
+ _requires_header_profile = True
+ _requires_header_menu = False
+
+ def get(self, _id):
+ record = self._get(_id)
+ return self._response_for_form(record)
+
+ def update(self, _id, **params):
+ record = self._get(_id)
+ record.write(self._prepare_params(params, mode="update"))
+ return self._response_for_form(record, message=self._msg_record_updated(record))
+
+ def _response_for_form(self, record, **kw):
+ record_data = self._record_data(record)
+ form_data = self._form_data(record)
+ return self._response(data={"record": record_data, "form": form_data}, **kw)
+
+ def _record_data(self, record):
+ raise NotImplementedError()
+
+ def _form_data(self, record):
+ raise NotImplementedError()
+
+ def _prepare_params(self, params, mode="update"):
+ return params
+
+ def _msg_record_updated(self, record):
+ model = self.env["ir.model"]._get(record._name)
+ body = _("%s updated.") % model.name
+ return {"message_type": "info", "body": body}
+
+
+class ShopfloorFormMixinValidator(AbstractComponent):
+ """Validators for the ShopfloorFormMixin endpoints"""
+
+ _inherit = "base.shopfloor.validator"
+ _name = "shopfloor.form.validator.mixin"
+ _usage = "form_mixin.validator"
+
+ def get(self):
+ raise NotImplementedError()
+
+ def update(self):
+ raise NotImplementedError()
+
+
+class ShopfloorFormMixinValidatorResponse(AbstractComponent):
+ """Validators for the ShopfloorFormMixin endpoints responses"""
+
+ _inherit = "base.shopfloor.validator.response"
+ _name = "shopfloor.form.validator.response.mixin"
+ _usage = "form_mixin.validator.response"
+
+ def get(self):
+ schema = {
+ "record": self.schemas._schema_dict_of(self._record_schema()),
+ "form": self.schemas._schema_dict_of(self._form_schema()),
+ }
+ return self._response_schema(schema)
+
+ def update(self):
+ return self.get()
+
+ def _record_schema(self):
+ raise NotImplementedError()
+
+ def _form_schema(self):
+ raise NotImplementedError()
diff --git a/shopfloor/services/forms/picking_form.py b/shopfloor/services/forms/picking_form.py
new file mode 100644
index 0000000000..dffebbf9ad
--- /dev/null
+++ b/shopfloor/services/forms/picking_form.py
@@ -0,0 +1,77 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# @author Simone Orsi
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo.addons.component.core import Component
+
+
+class ShopfloorPickingForm(Component):
+ """Allow to modify a stock.picking.
+
+ Editable fields: carrier_id.
+ """
+
+ _inherit = "shopfloor.form.mixin"
+ _name = "shopfloor.form.stock.picking"
+ _usage = "form_edit_stock_picking"
+ _description = __doc__
+ _expose_model = "stock.picking"
+
+ def _record_data(self, record):
+ # TODO: we use _detail here because it has the carrier info
+ # but is plenty of data we don't need -> add specific schema for forms
+ return self.data_detail.picking_detail(record)
+
+ def _form_data(self, record):
+ data = {}
+ available_carriers = self._get_available_carriers(record)
+ data["carrier_id"] = {
+ "value": record.carrier_id.id,
+ "select_options": available_carriers.jsonify(["id", "name"]),
+ }
+ return data
+
+ def _get_available_carriers(self, record):
+ company_carriers = self.env["delivery.carrier"].search(
+ ["|", ("company_id", "=", False), ("company_id", "=", record.company_id.id)]
+ )
+ available_carriers = company_carriers.available_carriers(record.partner_id)
+ return available_carriers
+
+
+class ShopfloorPickingFormValidator(Component):
+ """Validators for the ShopfloorPickingForm endpoints"""
+
+ _inherit = "shopfloor.form.validator.mixin"
+ _name = "shopfloor.form.stock.picking.validator"
+ _usage = "form_edit_stock_picking.validator"
+
+ def get(self):
+ return {}
+
+ def update(self):
+ return {
+ "carrier_id": {"type": "integer", "required": True},
+ }
+
+
+class ShopfloorPickingFormValidatorResponse(Component):
+ """Validators for the ShopfloorPickingForm endpoints responses"""
+
+ _inherit = "shopfloor.form.validator.response.mixin"
+ _name = "shopfloor.form.stock.picking.validator.response"
+ _usage = "form_edit_stock_picking.validator.response"
+
+ def _form_schema(self):
+ return {
+ "carrier_id": self.schemas._schema_dict_of(
+ {
+ "value": {"type": "integer", "required": True},
+ "select_options": self.schemas._schema_list_of(
+ self.schemas._simple_record()
+ ),
+ }
+ )
+ }
+
+ def _record_schema(self):
+ return self.schemas_detail.picking_detail()
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 3a8e6d7fdb..9df6449500 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -60,3 +60,4 @@
from . import test_move_action_assign
from . import test_scan_anything
from . import test_stock_split
+from . import test_picking_form
diff --git a/shopfloor/tests/test_picking_form.py b/shopfloor/tests/test_picking_form.py
new file mode 100644
index 0000000000..0ab9cc3e8c
--- /dev/null
+++ b/shopfloor/tests/test_picking_form.py
@@ -0,0 +1,61 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from .common import CommonCase
+
+
+class PickingFormCase(CommonCase):
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.menu = cls.env.ref("shopfloor.shopfloor_menu_checkout")
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+ cls.wh = cls.profile.warehouse_id
+ cls.picking_type = cls.menu.picking_type_ids
+
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.picking = cls._create_picking(lines=[(cls.product_a, 10)])
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(profile=self.profile) as work:
+ self.service = work.component(usage="form_edit_stock_picking")
+
+ def test_picking_form_get(self):
+ available_carriers = self.service._get_available_carriers(self.picking)
+ response = self.service.dispatch("get", _id=self.picking.id)
+ self.assert_response(
+ response,
+ data={
+ "record": self.data_detail.picking_detail(self.picking),
+ "form": {
+ "carrier_id": {
+ "value": self.picking.carrier_id.id,
+ "select_options": available_carriers.jsonify(["id", "name"]),
+ }
+ },
+ },
+ )
+
+ def test_picking_form_update(self):
+ available_carriers = self.service._get_available_carriers(self.picking)
+ self.picking.carrier_id = available_carriers[0]
+ params = {"carrier_id": available_carriers[1].id}
+ response = self.service.dispatch("update", _id=self.picking.id, params=params)
+ self.assert_response(
+ response,
+ data={
+ "record": self.data_detail.picking_detail(self.picking),
+ "form": {
+ "carrier_id": {
+ "value": self.picking.carrier_id.id,
+ "select_options": available_carriers.jsonify(["id", "name"]),
+ }
+ },
+ },
+ message=self.service._msg_record_updated(self.picking),
+ )
+ self.assertRecordValues(
+ self.picking, [{"carrier_id": available_carriers[1].id}]
+ )
From afe22f8305c5d9751326291f8eb42346ca329517 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 27 Oct 2020 11:00:33 +0100
Subject: [PATCH 414/940] shopfloor: picking info add picking_type_code
---
shopfloor/actions/data_detail.py | 1 +
shopfloor/services/schema_detail.py | 5 +++++
shopfloor/tests/test_actions_data_detail.py | 1 +
3 files changed, 7 insertions(+)
diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py
index 2bef510cbf..e3024da604 100644
--- a/shopfloor/actions/data_detail.py
+++ b/shopfloor/actions/data_detail.py
@@ -43,6 +43,7 @@ def pickings_detail(self, record, **kw):
@property
def _picking_detail_parser(self):
return self._picking_parser + [
+ "picking_type_code",
("priority", self._select_value_to_label),
"scheduled_date",
("picking_type_id:operation_type", ["id", "name"]),
diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py
index f57d40e33f..934c27b3d4 100644
--- a/shopfloor/services/schema_detail.py
+++ b/shopfloor/services/schema_detail.py
@@ -32,6 +32,11 @@ def picking_detail(self):
schema = self.picking()
schema.update(
{
+ "picking_type_code": {
+ "type": "string",
+ "nullable": True,
+ "required": False,
+ },
"priority": {"type": "string", "nullable": True, "required": False},
"operation_type": self._schema_dict_of(self._simple_record()),
"carrier": self._schema_dict_of(self._simple_record()),
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index 1595c0af85..c20b2f1dc0 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -181,6 +181,7 @@ def test_data_picking(self):
},
"carrier": {"id": carrier.id, "name": carrier.name},
"move_lines": self.data_detail.move_lines(picking.move_line_ids),
+ "picking_type_code": "outgoing",
}
self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13")
self.assertDictEqual(data, expected)
From bdb3c33d75d5829e4013ea79c735d6cd4179db3d Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 27 Oct 2020 11:01:01 +0100
Subject: [PATCH 415/940] shopfloor: location fix reserved move lines compute
---
shopfloor/models/stock_location.py | 2 +-
shopfloor/tests/test_actions_data_detail.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
index bb4f74f099..b66146f7ae 100644
--- a/shopfloor/models/stock_location.py
+++ b/shopfloor/models/stock_location.py
@@ -37,7 +37,7 @@ def is_sublocation_of(self, others, func=any):
def _get_reserved_move_lines(self):
return self.env["stock.move.line"].search(
[
- ("location_id", "=", self.id),
+ ("location_id", "child_of", self.id),
("product_uom_qty", ">", 0),
("state", "not in", ("done", "cancel")),
]
diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py
index c20b2f1dc0..a87ad57fc9 100644
--- a/shopfloor/tests/test_actions_data_detail.py
+++ b/shopfloor/tests/test_actions_data_detail.py
@@ -87,7 +87,7 @@ def test_data_location(self):
self.assert_schema(self.schema_detail.location_detail(), data)
move_lines = self.env["stock.move.line"].search(
[
- ("location_id", "=", location.id),
+ ("location_id", "child_of", location.id),
("product_qty", ">", 0),
("state", "not in", ("done", "cancel")),
]
From 1ade125b90a78f70f046a5db8accdfcd2c298117 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 26 Oct 2020 13:51:28 +0100
Subject: [PATCH 416/940] Prevent migration failure when exception is not
parsed as expected
When the exceptions doesn't contain 'Error:' (e.g. BdbQuit),
skip details instead of crashing.
---
shopfloor/migrations/13.0.1.2.0/post-migration.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/shopfloor/migrations/13.0.1.2.0/post-migration.py b/shopfloor/migrations/13.0.1.2.0/post-migration.py
index be81c55e98..e7f946fbcb 100644
--- a/shopfloor/migrations/13.0.1.2.0/post-migration.py
+++ b/shopfloor/migrations/13.0.1.2.0/post-migration.py
@@ -43,7 +43,9 @@ def _compute_logs_new_values(env):
else:
new_vals[fname] = json.dumps(val, indent=4, sort_keys=True)
if entry.error and not entry.exception_name:
- new_vals.update(_get_exception_details(entry))
+ exception_details = _get_exception_details(entry)
+ if exception_details:
+ new_vals.update(exception_details)
entry.write(new_vals)
From abb80456243f436ab54af58916a3c368748a0bd6 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 26 Oct 2020 15:25:01 +0100
Subject: [PATCH 417/940] shopfloor menus: add option to ignore unavailable
putaway
This option will be used in single pack transfer and location content
transfer.
---
shopfloor/actions/message.py | 6 +++++
shopfloor/models/shopfloor_menu.py | 39 ++++++++++++++++++++++++++++++
shopfloor/views/shopfloor_menu.xml | 15 ++++++++++++
3 files changed, 60 insertions(+)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index e600f00c4e..db9850e8ad 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -227,6 +227,12 @@ def no_pending_operation_for_pack(self, pack):
"body": _("No pending operation for package %s.") % pack.name,
}
+ def no_putaway_destination_available(self):
+ return {
+ "message_type": "error",
+ "body": _("No putaway destination is available."),
+ }
+
def unrecoverable_error(self):
return {
"message_type": "error",
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index 9429a8c142..c2a351282c 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -18,6 +18,11 @@ class ShopfloorMenu(models.Model):
"location_content_transfer",
)
+ _scenario_allowing_ignore_no_putaway_available = (
+ "single_pack_transfer",
+ "location_content_transfer",
+ )
+
name = fields.Char(translate=True)
sequence = fields.Integer()
profile_ids = fields.Many2many(
@@ -48,6 +53,16 @@ class ShopfloorMenu(models.Model):
help="If you tick this box, this scenario will allow operator to move"
" goods even if a reservation is made by a different operation type.",
)
+ ignore_no_putaway_available_is_possible = fields.Boolean(
+ compute="_compute_ignore_no_putaway_available_is_possible"
+ )
+ ignore_no_putaway_available = fields.Boolean(
+ string="Ignore transfers when no put-away is available",
+ default=False,
+ help="If you tick this box, the transfer is reserved only "
+ "if the put-away can find a sublocation (when putaway destination "
+ "is different from the operation type's destination).",
+ )
active = fields.Boolean(default=True)
def _selection_scenario(self):
@@ -92,6 +107,30 @@ def _compute_unreserve_other_moves_is_possible(self):
def onchange_unreserve_other_moves_is_possible(self):
self.allow_unreserve_other_moves = self.unreserve_other_moves_is_possible
+ @api.depends("scenario", "picking_type_ids")
+ def _compute_ignore_no_putaway_available_is_possible(self):
+ for menu in self:
+ menu.ignore_no_putaway_available_is_possible = bool(
+ menu.scenario in self._scenario_allowing_ignore_no_putaway_available
+ )
+
+ @api.onchange("ignore_no_putaway_available_is_possible")
+ def onchange_ignore_no_putaway_available_is_possible(self):
+ self.ignore_no_putaway_available = self.ignore_no_putaway_available_is_possible
+
+ @api.constrains("scenario", "picking_type_ids", "ignore_no_putaway_available")
+ def _check_ignore_no_putaway_available(self):
+ for menu in self:
+ if (
+ menu.ignore_no_putaway_available
+ and not menu.ignore_no_putaway_available_is_possible
+ ):
+ raise exceptions.ValidationError(
+ _("Ignoring not found putaway is not allowed for menu {}.").format(
+ menu.name
+ )
+ )
+
@api.constrains("scenario", "picking_type_ids", "allow_unreserve_other_moves")
def _check_allow_unreserve_other_moves(self):
for menu in self:
diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml
index 7777787659..1535799013 100644
--- a/shopfloor/views/shopfloor_menu.xml
+++ b/shopfloor/views/shopfloor_menu.xml
@@ -28,6 +28,11 @@
name="allow_unreserve_other_moves"
attrs="{'invisible': [('unreserve_other_moves_is_possible', '=', False)]}"
/>
+
+
@@ -79,6 +84,16 @@
/>
+
+
+
+
From 9a38be757eecbfb8c0c2a4293ea16497899d47ce Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 26 Oct 2020 15:26:59 +0100
Subject: [PATCH 418/940] single pack transfer: implement ignore no putaway
available option
When we start a single pack transfer, the moves (created or not by the
menu) are assigned. When we use this menu with putaways, we can
activate the option "ignore no putaway available".
When no putaway location is available, the destination location
remains the default one. If this option is active and the destination
location didn't change: rollback the action_assign. This will prevent
having move lines for things we don't know where to place.
---
shopfloor/services/single_pack_transfer.py | 17 +++++
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_single_pack_transfer.py | 2 +-
.../test_single_pack_transfer_putaway.py | 76 +++++++++++++++++++
4 files changed, 95 insertions(+), 1 deletion(-)
create mode 100644 shopfloor/tests/test_single_pack_transfer_putaway.py
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index e264be477a..4bddbd3bcc 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -133,6 +133,16 @@ def start(self, barcode, confirmation=False):
return self._response_for_start(
message=self.msg_store.no_pending_operation_for_pack(package)
)
+ if self.work.menu.ignore_no_putaway_available and self._no_putaway_available(
+ package_level
+ ):
+ # the putaway created a move line but no putaway was possible, so revert
+ # to the initial state
+ savepoint.rollback()
+ return self._response_for_start(
+ message=self.msg_store.no_putaway_destination_available()
+ )
+
if package_level.is_done and not confirmation:
return self._response_for_confirm_start(
package_level, message=self.msg_store.already_running_ask_confirmation()
@@ -146,6 +156,13 @@ def start(self, barcode, confirmation=False):
return self._response_for_scan_location(package_level)
+ def _no_putaway_available(self, package_level):
+ move_lines = package_level.move_line_ids
+ base_locations = self.picking_types.default_location_dest_id
+ # when no putaway is found, the move line destination stays the
+ # default's of the picking type
+ return any(line.location_dest_id in base_locations for line in move_lines)
+
def _create_package_level(self, package):
# this method can be called only if we have one picking type
# (allow_move_create==True on menu)
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 9df6449500..415f01e90c 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -7,6 +7,7 @@
from . import test_actions_data
from . import test_actions_data_detail
from . import test_single_pack_transfer
+from . import test_single_pack_transfer_putaway
from . import test_cluster_picking_base
from . import test_cluster_picking_batch
from . import test_cluster_picking_select
diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py
index e3d5916709..764e62f127 100644
--- a/shopfloor/tests/test_single_pack_transfer.py
+++ b/shopfloor/tests/test_single_pack_transfer.py
@@ -7,7 +7,7 @@
from .test_single_pack_transfer_base import SinglePackTransferCommonBase
-class SinglePackTransferCase(SinglePackTransferCommonBase):
+class TestSinglePackTransfer(SinglePackTransferCommonBase):
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
diff --git a/shopfloor/tests/test_single_pack_transfer_putaway.py b/shopfloor/tests/test_single_pack_transfer_putaway.py
new file mode 100644
index 0000000000..aeb953a498
--- /dev/null
+++ b/shopfloor/tests/test_single_pack_transfer_putaway.py
@@ -0,0 +1,76 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from .test_single_pack_transfer_base import SinglePackTransferCommonBase
+
+
+class TestSinglePackTransferPutaway(SinglePackTransferCommonBase):
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.pallets_storage_type = cls.env.ref(
+ "stock_storage_type.package_storage_type_pallets"
+ )
+ cls.main_pallets_location = cls.env.ref(
+ "stock_storage_type.stock_location_pallets"
+ )
+ cls.reserve_pallets_locations = cls.env.ref(
+ "stock_storage_type.stock_location_pallets_reserve"
+ )
+ cls.all_pallets_locations = (
+ cls.main_pallets_location.leaf_location_ids
+ | cls.reserve_pallets_locations.leaf_location_ids
+ )
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.package = cls.env["stock.quant.package"].create(
+ {
+ # this will parameterize the putaway to use pallet locations,
+ # and if not, it will stay on the picking type's default dest.
+ "package_storage_type_id": cls.pallets_storage_type.id,
+ }
+ )
+ cls._update_qty_in_location(cls.shelf1, cls.product_a, 10, package=cls.package)
+ cls.menu.sudo().ignore_no_putaway_available = True
+ cls.menu.sudo().allow_move_create = True
+
+ def test_normal_putaway(self):
+ """Ensure putaway is applied on moves"""
+ response = self.service.dispatch(
+ "start", params={"barcode": self.shelf1.barcode}
+ )
+ self.assert_response(
+ response, next_state="scan_location", data=self.ANY,
+ )
+ package_level_id = response["data"]["scan_location"]["id"]
+ package_level = self.env["stock.package_level"].browse(package_level_id)
+ self.assertIn(package_level.location_dest_id, self.all_pallets_locations)
+
+ def test_ignore_no_putaway_available(self):
+ """Ignore no putaway available is activated on the menu
+
+ In this case, when no putaway is possible, the changes
+ are rollbacked and an error is returned.
+ """
+ for location in self.all_pallets_locations:
+ package = self.env["stock.quant.package"].create(
+ {"package_storage_type_id": self.pallets_storage_type.id}
+ )
+ self._update_qty_in_location(location, self.product_a, 10, package=package)
+
+ response = self.service.dispatch(
+ "start", params={"barcode": self.shelf1.barcode}
+ )
+ self.assert_response(
+ response,
+ next_state="start",
+ message=self.service.msg_store.no_putaway_destination_available(),
+ )
+
+ package_levels = self.env["stock.package_level"].search(
+ [("package_id", "=", self.package.id)]
+ )
+ # no package level created to move the package
+ self.assertFalse(package_levels)
From fc36dcd9f1967d9c1d907bd4dc4cef2b8e825c73 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Mon, 26 Oct 2020 16:17:45 +0100
Subject: [PATCH 419/940] location transfer: implement ignore no putaway
available option
When we start a location content transfer, the moves (created or not by
the menu) are assigned. When we use this menu with putaways, we can
activate the option "ignore no putaway available".
When no putaway location is available, the destination location
remains the default one. If this option is active and the destination
location didn't change: rollback the action_assign. This will prevent
having move lines for things we don't know where to place.
---
.../services/location_content_transfer.py | 16 +++
.../test_location_content_transfer_putaway.py | 104 ++++++++++++++++++
.../test_location_content_transfer_start.py | 2 +-
3 files changed, 121 insertions(+), 1 deletion(-)
create mode 100644 shopfloor/tests/test_location_content_transfer_putaway.py
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 35f8395fb4..66c30026ba 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -354,6 +354,16 @@ def scan_location(self, barcode):
pickings = new_moves.mapped("picking_id")
move_lines = new_moves.move_line_ids
+ if self.work.menu.ignore_no_putaway_available and self._no_putaway_available(
+ move_lines
+ ):
+ # the putaway created a move line but no putaway was possible, so revert
+ # to the initial state
+ savepoint.rollback()
+ return self._response_for_start(
+ message=self.msg_store.no_putaway_destination_available()
+ )
+
if not pickings:
return self._response_for_start(
message=self.msg_store.location_empty(location)
@@ -371,6 +381,12 @@ def scan_location(self, barcode):
return self._router_single_or_all_destination(pickings)
+ def _no_putaway_available(self, move_lines):
+ base_locations = self.picking_types.default_location_dest_id
+ # when no putaway is found, the move line destination stays the
+ # default's of the picking type
+ return any(line.location_dest_id in base_locations for line in move_lines)
+
def _find_transfer_move_lines_domain(self, location):
return [
("location_id", "=", location.id),
diff --git a/shopfloor/tests/test_location_content_transfer_putaway.py b/shopfloor/tests/test_location_content_transfer_putaway.py
new file mode 100644
index 0000000000..5b82a61f41
--- /dev/null
+++ b/shopfloor/tests/test_location_content_transfer_putaway.py
@@ -0,0 +1,104 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from .test_location_content_transfer_base import LocationContentTransferCommonCase
+
+
+class TestLocationContentTransferPutaway(LocationContentTransferCommonCase):
+ """Tests with putaway when using option to ignore unavailable putaway locations
+ """
+
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.pallets_storage_type = cls.env.ref(
+ "stock_storage_type.package_storage_type_pallets"
+ )
+ cls.main_pallets_location = cls.env.ref(
+ "stock_storage_type.stock_location_pallets"
+ )
+ cls.reserve_pallets_locations = cls.env.ref(
+ "stock_storage_type.stock_location_pallets_reserve"
+ )
+ cls.all_pallets_locations = (
+ cls.main_pallets_location.leaf_location_ids
+ | cls.reserve_pallets_locations.leaf_location_ids
+ )
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.package = cls.env["stock.quant.package"].create(
+ {
+ # this will parameterize the putaway to use pallet locations,
+ # and if not, it will stay on the picking type's default dest.
+ "package_storage_type_id": cls.pallets_storage_type.id,
+ }
+ )
+ cls.package2 = cls.env["stock.quant.package"].create(
+ {
+ # this will parameterize the putaway to use pallet locations,
+ # and if not, it will stay on the picking type's default dest.
+ "package_storage_type_id": cls.pallets_storage_type.id,
+ }
+ )
+ # create a location to be sure it's empty
+ cls.test_loc = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "location_id": cls.stock_location.id,
+ "name": "test",
+ "barcode": "test_loc",
+ }
+ )
+ )
+ cls._update_qty_in_location(
+ cls.test_loc, cls.product_a, 10, package=cls.package
+ )
+ cls._update_qty_in_location(
+ cls.test_loc, cls.product_a, 10, package=cls.package2
+ )
+ cls.menu.sudo().allow_move_create = True
+ cls.menu.sudo().ignore_no_putaway_available = True
+ cls.menu.sudo().allow_unreserve_other_moves = True
+
+ def test_normal_putaway(self):
+ """Ensure putaway is applied on moves"""
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.test_loc.barcode}
+ )
+ self.assert_response(
+ response, next_state="start_single", data=self.ANY,
+ )
+ package_level_id = response["data"]["start_single"]["package_level"]["id"]
+ package_level = self.env["stock.package_level"].browse(package_level_id)
+ self.assertIn(package_level.location_dest_id, self.all_pallets_locations)
+
+ def test_ignore_no_putaway_available(self):
+ """Ignore no putaway available is activated on the menu
+
+ In this case, when no putaway is possible, the changes
+ are rollbacked and an error is returned.
+ """
+ for location in self.all_pallets_locations:
+ package = self.env["stock.quant.package"].create(
+ {"package_storage_type_id": self.pallets_storage_type.id}
+ )
+ self._update_qty_in_location(location, self.product_a, 10, package=package)
+
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.test_loc.barcode}
+ )
+ self.assert_response(
+ response,
+ next_state="start",
+ message=self.service.msg_store.no_putaway_destination_available(),
+ )
+
+ package_levels = self.env["stock.package_level"].search(
+ [("package_id", "in", (self.package.id, self.package2.id))]
+ )
+ # no package level created to move the package
+ self.assertFalse(package_levels)
diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py
index 29d0470d16..c193fd0316 100644
--- a/shopfloor/tests/test_location_content_transfer_start.py
+++ b/shopfloor/tests/test_location_content_transfer_start.py
@@ -4,7 +4,7 @@
from .test_location_content_transfer_base import LocationContentTransferCommonCase
-class LocationContentTransferStartCase(LocationContentTransferCommonCase):
+class TestLocationContentTransferStart(LocationContentTransferCommonCase):
"""Tests for start state and recover
Endpoints:
From ecd2168646cdead7c9711c139f6803d24fde5aab Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Tue, 27 Oct 2020 08:26:08 +0100
Subject: [PATCH 420/940] shopfloor menus: Remove editable list view
The list view has now too many options, show them and edit only in form
view.
---
shopfloor/views/shopfloor_menu.xml | 17 +----------------
1 file changed, 1 insertion(+), 16 deletions(-)
diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml
index 1535799013..552c2e7193 100644
--- a/shopfloor/views/shopfloor_menu.xml
+++ b/shopfloor/views/shopfloor_menu.xml
@@ -4,7 +4,7 @@
shopfloor menu tree
shopfloor.menu
-
+
@@ -18,21 +18,6 @@
widget="many2many_tags"
options="{'no_create': 1}"
/>
-
-
-
-
-
-
From 7b60c5839f0bcfa481c08fac44a08b2eb9e5bdc7 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 27 Oct 2020 11:32:47 +0100
Subject: [PATCH 421/940] shopfloor: bump 13.0.1.3.0
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index f7d6b4a123..0adc6d0d30 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.2.0",
+ "version": "13.0.1.3.0",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From 79a5469ccee61ab159417b0c0b94c0ca51f14b5f Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Fri, 30 Oct 2020 12:02:52 +0100
Subject: [PATCH 422/940] location transfer: allow to scan source package on
lines
When move lines have no package level (e.g. we use the "Open Package"
button), they still have the package in "package_id".
Users should be able to scan this package to identify that they
want to take the products from there (they may have no label on
products).
---
shopfloor/services/location_content_transfer.py | 7 +++++++
shopfloor/tests/test_location_content_transfer_single.py | 5 +++++
2 files changed, 12 insertions(+)
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 66c30026ba..c2a8dbe4c2 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -575,6 +575,13 @@ def scan_line(self, location_id, move_line_id, barcode):
)
search = self.actions_for("search")
+
+ package = search.package_from_scan(barcode)
+ if package and 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 product and product == move_line.product_id:
if product.tracking in ("lot", "serial"):
diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py
index 74f4ecc1f5..1a4c2daeca 100644
--- a/shopfloor/tests/test_location_content_transfer_single.py
+++ b/shopfloor/tests/test_location_content_transfer_single.py
@@ -231,6 +231,11 @@ def _test_scan_line_ok(self, move_line, barcode):
)
self.assert_response_scan_destination(response, move_line)
+ def test_scan_line_package_ok(self):
+ move_line = self.picking2.move_line_ids[0]
+ package = move_line.package_id = self.env["stock.quant.package"].create({})
+ self._test_scan_line_ok(move_line, package.name)
+
def test_scan_line_product_ok(self):
move_line = self.picking2.move_line_ids[0]
# check we selected the good line
From bcbf642bdb4e867ddf89ba8d0e3d6564a2583b21 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Mon, 2 Nov 2020 10:27:04 +0100
Subject: [PATCH 423/940] shopfloor: fix singleton error
---
shopfloor/models/stock_move_line.py | 15 +++++++--------
1 file changed, 7 insertions(+), 8 deletions(-)
diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py
index bd8b2c3de3..f6105e4d55 100644
--- a/shopfloor/models/stock_move_line.py
+++ b/shopfloor/models/stock_move_line.py
@@ -89,14 +89,13 @@ def _split_pickings_from_source_location(self):
"backorder_id": picking.id,
}
)
- pickings.message_post(
- body=_(
- 'The backorder %s has been created.'
- )
- % (new_picking.id, new_picking.name)
- )
+ message = _(
+ 'The backorder %s has been created.'
+ ) % (new_picking.id, new_picking.name)
+ for pick in pickings:
+ pick.message_post(body=message)
new_moves.write({"picking_id": new_picking.id})
new_moves.mapped("move_line_ids").write({"picking_id": new_picking.id})
new_moves.move_line_ids.package_level_id.write(
From 9cb65291b94bd77204dabed69368d7ab3a5c9968 Mon Sep 17 00:00:00 2001
From: oca-travis
Date: Tue, 3 Nov 2020 13:45:02 +0000
Subject: [PATCH 424/940] [UPD] Update shopfloor.pot
---
shopfloor/i18n/shopfloor.pot | 1422 ++++++++++++++++++++++++++++++++++
1 file changed, 1422 insertions(+)
create mode 100644 shopfloor/i18n/shopfloor.pot
diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot
new file mode 100644
index 0000000000..0ffe4b6c66
--- /dev/null
+++ b/shopfloor/i18n/shopfloor.pot
@@ -0,0 +1,1422 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * shopfloor
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 13.0\n"
+"Report-Msgid-Bugs-To: \n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/forms/form_mixin.py:0
+#, python-format
+msgid "%s updated."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "A destination package is required."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/change_package_lot.py:0
+#, python-format
+msgid "A draft inventory has been created for control."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check
+msgid "Activate Zero Check"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__active
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__active
+msgid "Active"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin
+msgid "Adds shopfloor priority/postpone fields"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "All packages processed."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create
+msgid "Allow Move Creation"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves
+msgid "Allow to process reserved quantities"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_form_view
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_search_view
+msgid "Archived"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Are you sure?"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.actions.server,name:shopfloor.ir_cron_autovacuum_shopfloor_log_ir_actions_server
+#: model:ir.cron,cron_name:shopfloor.ir_cron_autovacuum_shopfloor_log
+#: model:ir.cron,name:shopfloor.ir_cron_autovacuum_shopfloor_log
+msgid "Auto-vacuum Shopfloor Logs"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Barcode does not match with {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Barcode not found"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_picking_batch
+msgid "Batch Transfer"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Batch Transfer complete"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Batch Transfer line done"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Bin %s doesn't exist"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Canceled, you can scan a new pack."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Cannot change to lot {} which is entirely picked."
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_checkout
+#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo
+msgid "Checkout"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_res_partner__shopfloor_packing_info
+#: model:ir.model.fields,field_description:shopfloor.field_res_users__shopfloor_packing_info
+msgid "Checkout Packing Information"
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_cluster_picking
+#: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo
+msgid "Cluster Picking"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Confirm location change from %s to %s?"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_res_partner
+msgid "Contact"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Content transfer to {} completed"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Content transferred from {} to {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/inventory.py:0
+#, python-format
+msgid "Control stock issue in location {} for {}"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_uid
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_uid
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_uid
+msgid "Created by"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_move.py:0
+#, python-format
+msgid ""
+"Created from backorder %s ."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_date
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_date
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_date
+msgid "Created on"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/shopfloor_menu.py:0
+#, python-format
+msgid "Creation of moves is not allowed for menu {}."
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Date"
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery
+#: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo
+msgid "Delivery"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__display_name
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__display_name
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__display_name
+msgid "Display Name"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_display_packing_info
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_display_packing_info
+msgid "Display customer packing info"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__error
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view
+msgid "Error"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_name
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Exception"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_message
+msgid "Exception Message"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Exception message"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__failed
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Failed"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check
+msgid ""
+"For Shopfloor scenarios using it (Cluster Picking, Zone Picking, Discrete "
+"order Picking), the zero check step will be activated when a location "
+"becomes empty after a move."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_picking__shopfloor_display_packing_info
+#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_display_packing_info
+msgid ""
+"For the Shopfloor Checkout/Packing scenarios to display the customer packing"
+" info."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id
+msgid "From"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__functional
+msgid "Functional"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Functional errors"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Group By"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__headers
+msgid "Headers"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__id
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__id
+msgid "ID"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available
+msgid ""
+"If you tick this box, the transfer is reserved only if the put-away can find"
+" a sublocation (when putaway destination is different from the operation "
+"type's destination)."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves
+msgid ""
+"If you tick this box, this scenario will allow operator to move goods even "
+"if a reservation is made by a different operation type."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible
+msgid "Ignore No Putaway Available Is Possible"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available
+msgid "Ignore transfers when no put-away is available"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/shopfloor_menu.py:0
+#, python-format
+msgid "Ignoring not found putaway is not allowed for menu {}."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_inventory
+msgid "Inventory"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_location
+msgid "Inventory Locations"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log____last_update
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu____last_update
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile____last_update
+msgid "Last Modified on"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_uid
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_uid
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_uid
+msgid "Last Updated by"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_date
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_date
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_date
+msgid "Last Updated on"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/completion_info.py:0
+#, python-format
+msgid ""
+"Last operation of transfer {}. Next operation ({}) is ready to proceed."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Line cancelled"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Lines have different destination location."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Location %s doesn't contain any package."
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_location_content_transfer
+#: model:stock.picking.type,name:shopfloor.picking_type_location_content_transfer_demo
+msgid "Location Content Transfer"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Location not allowed here."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Location {} empty"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Logs generated today"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Lot is not in the current transfer."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/delivery.py:0
+#, python-format
+msgid "Lot {} belongs to a picking without a valid state."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Lot {} is for another product."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Lot {} replaced by lot {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/inventory.py:0
+#, python-format
+msgid "Lot: "
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_shopfloor_menu
+msgid "Menu displayed in the scanner application"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_menu
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__menu_ids
+#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_menu
+msgid "Menus"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_profile__menu_ids
+msgid "Menus visible for this profile"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible
+msgid "Move Create Is Possible"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids
+msgid "Move Line"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__move_line_count
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__move_line_count
+msgid "Move Line Count"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_move_line.py:0
+#, python-format
+msgid "Move lines processed have to share the same source location."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__name
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__name
+msgid "Name"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Negative quantity not allowed."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "New move lines cannot be assigned: canceled."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No lines to process."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No location found for this barcode."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No lot found among current transfers."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No lot found for {}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/cluster_picking.py:0
+#, python-format
+msgid "No more work to do, please create a new batch transfer"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No operation type found for this menu and profile."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/service.py:0
+#, python-format
+msgid "No operation types configured on menu {} for warehouse {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No package or lot found for barcode {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No pending operation for package %s."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No product found among current transfers."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No putaway destination is available."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No quantity has been processed, unable to complete the transfer."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "No valid package to select."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Not a valid destination package"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid ""
+"Not all lines have been processed with full quantity. Do you confirm partial"
+" operation?"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid ""
+"Not allowed to pack more than the quantity, the value has been changed to "
+"the maximum."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__picking_type_ids
+msgid "Operation Types"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Operation already processed."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Operation's already running. Would you like to take it over?"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Package cancelled"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package has been opened. You can move partial quantities."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/location_content_transfer.py:0
+#, python-format
+msgid "Package level has to be in draft"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_quant_package.py:0
+#, python-format
+msgid "Package name must be unique!"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/delivery.py:0
+#, python-format
+msgid "Package {} belongs to a picking without a valid state."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} cannot be picked, already moved by transfer {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} cannot be used: {} "
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_move_line.py:0
+#, python-format
+msgid ""
+"Package {} does not contain available product {}, cannot replace package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} has a different content."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_move_line.py:0
+#, python-format
+msgid "Package {} has been partially picked in another location"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} is already used."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} is not available in transfer {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} is not empty."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Package {} is not in the current transfer."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} replaced by package {}."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_quant_package
+msgid "Packages"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Packaging changed on package {}"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_packing_info
+msgid "Packing information"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view
+msgid "Parameters"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__params
+msgid "Params"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/change_package_lot.py:0
+#, python-format
+msgid "Pick: stock issue on lot: {} found in {}"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count
+msgid "Picking Count"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_picking_type
+msgid "Picking Type"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Picking has already been started in this location in transfer(s): {}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Picking type {} complete."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids
+msgid "Planned Move Line"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/shopfloor_menu.py:0
+#, python-format
+msgid "Processing reserved quantities is not allowed for menu {}."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_move_line
+msgid "Product Moves (Stock Move Line)"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Product is not in the current transfer."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Product tracked by lot, please scan one."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/delivery.py:0
+#, python-format
+msgid "Product {} belongs to a picking without a valid state."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Product(s) packed in {}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Product(s) processed as raw product(s)"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_profile
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__profile_ids
+#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_profile
+msgid "Profiles"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_quant
+msgid "Quants"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/scan_anything.py:0
+#, python-format
+msgid ""
+"Record not found.\n"
+"We've tried with the following types: {}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Recovered previous session."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Remaining raw product not packed, proceed anyway?"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_method
+msgid "Request Method"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_url
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Request URL"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_location__reserved_move_line_ids
+#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__reserved_move_line_ids
+msgid "Reserved Move Line"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Restart the operation, someone has canceled it."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__result
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view
+msgid "Result"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree
+msgid "SF Priority"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree
+msgid "SF checkout done"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree
+msgid "SF unloaded"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Scan the destination location"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/location_content_transfer.py:0
+#: code:addons/shopfloor/services/location_content_transfer.py:0
+#, python-format
+msgid "Scan the package"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scenario
+msgid "Scenario"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view
+msgid "Scenario Options"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/shopfloor_menu.py:0
+#, python-format
+msgid ""
+"Scenario `{}` require(s) 'Move Entire Packages' to be enabled.\n"
+"These type(s) do not satisfy this constraint: \n"
+"{}.\n"
+"Please, adjust your configuration."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__sequence
+msgid "Sequence"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Several lines found in %s, please scan one."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Several lots found in %s, please scan a lot."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Several operation types found for this menu and profile."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Several packages found in %s, please scan a package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Several products found in %s, please scan a product."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid ""
+"Several transfers found, please scan a package or select a transfer "
+"manually."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__severe
+msgid "Severe"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Severe errors"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__severity
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Severity"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.ui.menu,name:shopfloor.menu_shopfloor_settings
+#: model_terms:ir.ui.view,arch_db:shopfloor.res_partner_shopfloor_form
+#: model_terms:ir.ui.view,arch_db:shopfloor.view_location_form
+#: model_terms:ir.ui.view,arch_db:shopfloor.view_picking_type_form
+msgid "Shopfloor"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done
+msgid "Shopfloor Checkout Done"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_shopfloor_log
+msgid "Shopfloor Logging"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_log
+#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_log
+msgid "Shopfloor Logs"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids
+msgid "Shopfloor Menus"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence
+msgid "Shopfloor Picking Sequence"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_postponed
+#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_postponed
+msgid "Shopfloor Postponed"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_priority
+#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_priority
+msgid "Shopfloor Priority"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded
+msgid "Shopfloor Unloaded"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id
+msgid "Shopfloor User"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_shopfloor_profile
+msgid "Shopfloor profile settings"
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_single_pallet_transfer
+#: model:stock.picking.type,name:shopfloor.picking_type_single_pallet_transfer_demo
+msgid "Single Pallet Transfer"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create
+msgid ""
+"Some scenario may create move(s) when a product or package is scanned and no"
+" move already exists. Any new move is created in the selected operation "
+"type, so it can be active only when one type is selected."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_location__source_move_line_ids
+msgid "Source Move Line"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id
+msgid "Source Package"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__state
+msgid "State"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Status"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_move
+msgid "Stock Move"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_package_level
+msgid "Stock Package Level"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id
+msgid "Stock Picking"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__success
+msgid "Success"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed
+#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_postponed
+#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_postponed
+msgid ""
+"Technical field. Indicates if the operation has been postponed in a barcode "
+"scenario."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_picking__move_line_count
+#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__move_line_count
+msgid "Technical field. Indicates number of move lines included."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count
+msgid "Technical field. Indicates number of transfers included."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_picking__total_weight
+#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__total_weight
+msgid "Technical field. Indicates total weight of transfers included."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__planned_move_line_ids
+msgid "Technical field. Move lines for which destination is this package."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__move_line_ids
+msgid "Technical field. Move lines moving this package."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority
+#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_priority
+#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_priority
+msgid "Technical field. Overrides operation priority in barcode scenario."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_move_line.py:0
+#, python-format
+msgid ""
+"The backorder %s has been created."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/cluster_picking.py:0
+#, python-format
+msgid "The destination bin {} is not empty, please take another."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "The pack has been moved, you can scan a new pack."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "The package %s doesn't exist"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence
+msgid ""
+"The picking done in Shopfloor scenarios will respect this order. The "
+"sequence is a char so it can be composed of fields such as 'corridor-rack-"
+"side-level'. Pay attention to the padding ('09' is before '19', '9' is not)."
+" It is recommended to use an Export then an Import to populate this field "
+"using a spreadsheet."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/service.py:0
+#, python-format
+msgid "The record %s %s does not exist"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "The record you were working on does not exist anymore."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_move_line__picking_id
+msgid "The stock operation where the packing has been made"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/cluster_picking.py:0
+#, python-format
+msgid "This batch cannot be selected."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This line has a package, please select the package instead."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This line is not available in transfer {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/location_content_transfer.py:0
+#, python-format
+msgid "This location content can't be moved at once."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/location_content_transfer.py:0
+#, python-format
+msgid "This location content can't be moved using this menu."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This lot does not exist anymore."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid ""
+"This lot is part of a package with other products, please scan a package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This lot is part of multiple packages, please scan a package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This operation does not exist anymore."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This package does not exist anymore."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This product does not exist anymore."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid ""
+"This product is part of a package with other products, please scan a "
+"package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This product is part of multiple packages, please scan a package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This transfer does not exist or is not available anymore."
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Today"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__total_weight
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__total_weight
+msgid "Total Weight"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_picking
+msgid "Transfer"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Transfer {} complete"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Transfer {} done"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Transfer {} is not available."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Units replaced by package {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Unrecoverable error, please restart."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unreserve_other_moves_is_possible
+msgid "Unreserve Other Moves Is Possible"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "User"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_ids
+msgid "Visible for these profiles"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__warehouse_id
+msgid "Warehouse"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__warning
+msgid "Warning"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Warning errors"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/cluster_picking.py:0
+#, python-format
+msgid "Wrong bin"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "You cannot move this using this menu."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "You cannot place it here"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "You cannot work on a package (%s) outside of locations: %s"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "You must not pick more than {} units."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/inventory.py:0
+#, python-format
+msgid "Zero check issue on location {}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/inventory.py:0
+#, python-format
+msgid "Zero check issue on location {} ({})"
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_zone_picking
+#: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo
+msgid "Zone Picking"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/inventory.py:0
+#, python-format
+msgid ""
+"{picking.name} stock correction in location {location.name} for "
+"{product_desc}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "{} {} put in {}"
+msgstr ""
From 46950f8ea1f58b1b6962c0ae8ceba664645d95eb Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Tue, 3 Nov 2020 14:23:23 +0000
Subject: [PATCH 425/940] [ADD] icon.png
---
shopfloor/static/description/icon.png | Bin 0 -> 9455 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 shopfloor/static/description/icon.png
diff --git a/shopfloor/static/description/icon.png b/shopfloor/static/description/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d
GIT binary patch
literal 9455
zcmW++2RxMjAAjx~&dlBk9S+%}OXg)AGE&Cb*&}d0jUxM@u(PQx^-s)697TX`ehR4?GS^qbkof1cslKgkU)h65qZ9Oc=ml_0temigYLJfnz{IDzUf>bGs4N!v3=Z3jMq&A#7%rM5eQ#dc?k~!
zVpnB`o+K7|Al`Q_U;eD$B
zfJtP*jH`siUq~{KE)`jP2|#TUEFGRryE2`i0**z#*^6~AI|YzIWy$Cu#CSLW3q=GA
z6`?GZymC;dCPk~rBS%eCb`5OLr;RUZ;D`}um=H)BfVIq%7VhiMr)_#G0N#zrNH|__
zc+blN2UAB0=617@>_u;MPHN;P;N#YoE=)R#i$k_`UAA>WWCcEVMh~L_
zj--gtp&|K1#58Yz*AHCTMziU1Jzt_jG0I@qAOHsk$2}yTmVkBp_eHuY$A9)>P6o~I
z%aQ?!(GqeQ-Y+b0I(m9pwgi(IIZZzsbMv+9w{PFtd_<_(LA~0H(xz{=FhLB@(1&qHA5EJw1>>=%q2f&^X>IQ{!GJ4e9U
z&KlB)z(84HmNgm2hg2C0>WM{E(DdPr+EeU_N@57;PC2&DmGFW_9kP&%?X4}+xWi)(
z;)z%wI5>D4a*5XwD)P--sPkoY(a~WBw;E~AW`Yue4kFa^LM3X`8x|}ZUeMnqr}>kH
zG%WWW>3ml$Yez?i%)2pbKPI7?5o?hydokgQyZsNEr{a|mLdt;X2TX(#B1j35xPnPW
z*bMSSOauW>o;*=kO8ojw91VX!qoOQb)zHJ!odWB}d+*K?#sY_jqPdg{Sm2HdYzdEx
zOGVPhVRTGPtv0o}RfVP;Nd(|CB)I;*t&QO8h
zFfekr30S!-LHmV_Su-W+rEwYXJ^;6&3|L$mMC8*bQptyOo9;>Qb9Q9`ySe3%V$A*9
zeKEe+b0{#KWGp$F+tga)0RtI)nhMa-K@JS}2krK~n8vJ=Ngm?R!9G<~RyuU0d?nz#
z-5EK$o(!F?hmX*2Yt6+coY`6jGbb7tF#6nHA
zuKk=GGJ;ZwON1iAfG$E#Y7MnZVmrY|j0eVI(DN_MNFJmyZ|;w4tf@=CCDZ#5N_0K=
z$;R~bbk?}TpfDjfB&aiQ$VA}s?P}xPERJG{kxk5~R`iRS(SK5d+Xs9swCozZISbnS
zk!)I0>t=A<-^z(cmSFz3=jZ23u13X><0b)P)^1T_))Kr`e!-pb#q&J*Q`p+B6la%C
zuVl&0duN<;uOsB3%T9Fp8t{ED108)`y_~Hnd9AUX7h-H?jVuU|}My+C=TjH(jKz
zqMVr0re3S$H@t{zI95qa)+Crz*5Zj}Ao%4Z><+W(nOZd?gDnfNBC3>M8WE61$So|P
zVvqH0SNtDTcsUdzaMDpT=Ty0pDHHNL@Z0w$Y`XO
z2M-_r1S+GaH%pz#Uy0*w$Vdl=X=rQXEzO}d6J^R6zjM1u&c9vYLvLp?W7w(?np9x1
zE_0JSAJCPB%i7p*Wvg)pn5T`8k3-uR?*NT|J`eS#_#54p>!p(mLDvmc-3o0mX*mp_
zN*AeS<>#^-{S%W<*mz^!X$w_2dHWpcJ6^j64qFBft-o}o_Vx80o0>}Du;>kLts;$8
zC`7q$QI(dKYG`Wa8#wl@V4jVWBRGQ@1dr-hstpQL)Tl+aqVpGpbSfN>5i&QMXfiZ>
zaA?T1VGe?rpQ@;+pkrVdd{klI&jVS@I5_iz!=UMpTsa~mBga?1r}aRBm1WS;TT*s0f0lY=JBl66Upy)-k4J}lh=P^8(SXk~0xW=T9v*B|gzIhN
z>qsO7dFd~mgxAy4V?&)=5ieYq?zi?ZEoj)&2o)RLy=@hbCRcfT5jigwtQGE{L*8<@Yd{zg;CsL5mvzfDY}P-wos_6PfprFVaeqNE%h
zKZhLtcQld;ZD+>=nqN~>GvROfueSzJD&BE*}XfU|H&(FssBqY=hPCt`d
zH?@s2>I(|;fcW&YM6#V#!kUIP8$Nkdh0A(bEVj``-AAyYgwY~jB
zT|I7Bf@%;7aL7Wf4dZ%VqF$eiaC38OV6oy3Z#TER2G+fOCd9Iaoy6aLYbPTN{XRPz
z;U!V|vBf%H!}52L2gH_+j;`bTcQRXB+y9onc^wLm5wi3-Be}U>k_u>2Eg$=k!(l@I
zcCg+flakT2Nej3i0yn+g+}%NYb?ta;R?(g5SnwsQ49U8Wng8d|{B+lyRcEDvR3+`O{zfmrmvFrL6acVP%yG98X
zo&+VBg@px@i)%o?dG(`T;n*$S5*rnyiR#=wW}}GsAcfyQpE|>a{=$Hjg=-*_K;UtD
z#z-)AXwSRY?OPefw^iI+
z)AXz#PfEjlwTes|_{sB?4(O@fg0AJ^g8gP}ex9Ucf*@_^J(s_5jJV}c)s$`Myn|Kd
z$6>}#q^n{4vN@+Os$m7KV+`}c%4)4pv@06af4-x5#wj!KKb%caK{A&Y#Rfs
z-po?Dcb1({W=6FKIUirH&(yg=*6aLCekcKwyfK^JN5{wcA3nhO(o}SK#!CINhI`-I
z1)6&n7O&ZmyFMuNwvEic#IiOAwNkR=u5it{B9n2sAJV5pNhar=j5`*N!Na;c7g!l$
z3aYBqUkqqTJ=Re-;)s!EOeij=7SQZ3Hq}ZRds%IM*PtM$wV
z@;rlc*NRK7i3y5BETSKuumEN`Xu_8GP1Ri=OKQ$@I^ko8>H6)4rjiG5{VBM>B|%`&&s^)jS|-_95&yc=GqjNo{zFkw%%HHhS~e=s
zD#sfS+-?*t|J!+ozP6KvtOl!R)@@-z24}`9{QaVLD^9VCSR2b`b!KC#o;Ki<+wXB6
zx3&O0LOWcg4&rv4QG0)4yb}7BFSEg~=IR5#ZRj8kg}dS7_V&^%#Do==#`u
zpy6{ox?jWuR(;pg+f@mT>#HGWHAJRRDDDv~@(IDw&R>9643kK#HN`!1vBJHnC+RM&yIh8{gG2q
zA%e*U3|N0XSRa~oX-3EAneep)@{h2vvd3Xvy$7og(sayr@95+e6~Xvi1tUqnIxoIH
zVWo*OwYElb#uyW{Imam6f2rGbjR!Y3`#gPqkv57dB6K^wRGxc9B(t|aYDGS=m$&S!NmCtrMMaUg(c
zc2qC=2Z`EEFMW-me5B)24AqF*bV5Dr-M5ig(l-WPS%CgaPzs6p_gnCIvTJ=Y<6!gT
zVt@AfYCzjjsMEGi=rDQHo0yc;HqoRNnNFeWZgcm?f;cp(6CNylj36DoL(?TS7eU#+
z7&mfr#y))+CJOXQKUMZ7QIdS9@#-}7y2K1{8)cCt0~-X0O!O?Qx#E4Og+;A2SjalQ
zs7r?qn0H044=sDN$SRG$arw~n=+T_DNdSrarmu)V6@|?1-ZB#hRn`uilTGPJ@fqEy
zGt(f0B+^JDP&f=r{#Y_wi#AVDf-y!RIXU^0jXsFpf>=Ji*TeqSY!H~AMbJdCGLhC)
zn7Rx+sXw6uYj;WRYrLd^5IZq@6JI1C^YkgnedZEYy<&4(z%Q$5yv#Boo{AH8n$a
zhb4Y3PWdr269&?V%uI$xMcUrMzl=;w<_nm*qr=c3Rl@i5wWB;e-`t7D&c-mcQl7x!
zZWB`UGcw=Y2=}~wzrfLx=uet<;m3~=8I~ZRuzvMQUQdr+yTV|ATf1Uuomr__nDf=X
zZ3WYJtHp_ri(}SQAPjv+Y+0=fH4krOP@S&=zZ-t1jW1o@}z;xk8
z(Nz1co&El^HK^NrhVHa-_;&88vTU>_J33=%{if;BEY*J#1n59=07jrGQ#IP>@u#3A
z;!q+E1Rj3ZJ+!4bq9F8PXJ@yMgZL;>&gYA0%_Kbi8?S=XGM~dnQZQ!yBSgcZhY96H
zrWnU;k)qy`rX&&xlDyA%(a1Hhi5CWkmg(`Gb%m(HKi-7Z!LKGRP_B8@`7&hdDy5n=
z`OIxqxiVfX@OX1p(mQu>0Ai*v_cTMiw4qRt3~NBvr9oBy0)r>w3p~V0SCm=An6@3n)>@z!|o-$HvDK
z|3D2ZMJkLE5loMKl6R^ez@Zz%S$&mbeoqH5`Bb){Ei21q&VP)hWS2tjShfFtGE+$z
zzCR$P#uktu+#!w)cX!lWN1XU%K-r=s{|j?)Akf@q#3b#{6cZCuJ~gCxuMXRmI$nGtnH+-h
z+GEi!*X=AP<|fG`1>MBdTb?28JYc=fGvAi2I<$B(rs$;eoJCyR6_bc~p!XR@O-+sD
z=eH`-ye})I5ic1eL~TDmtfJ|8`0VJ*Yr=hNCd)G1p2MMz4C3^Mj?7;!w|Ly%JqmuW
zlIEW^Ft%z?*|fpXda>Jr^1noFZEwFgVV%|*XhH@acv8rdGxeEX{M$(vG{Zw+x(ei@
zmfXb22}8-?Fi`vo-YVrTH*C?a8%M=Hv9MqVH7H^J$KsD?>!SFZ;ZsvnHr_gn=7acz
z#W?0eCdVhVMWN12VV^$>WlQ?f;P^{(&pYTops|btm6aj>_Uz+hqpGwB)vWp0Cf5y<
zft8-je~nn?W11plq}N)4A{l8I7$!ks_x$PXW-2XaRFswX_BnF{R#6YIwMhAgd5F9X
zGmwdadS6(a^fjHtXg8=l?Rc0Sm%hk6E9!5cLVloEy4eh(=FwgP`)~I^5~pBEWo+F6
zSf2ncyMurJN91#cJTy_u8Y}@%!bq1RkGC~-bV@SXRd4F{R-*V`bS+6;W5vZ(&+I<9$;-V|eNfLa5n-6%
z2(}&uGRF;p92eS*sE*oR$@pexaqr*meB)VhmIg@h{uzkk$9~qh#cHhw#>O%)b@+(|
z^IQgqzuj~Sk(J;swEM-3TrJAPCq9k^^^`q{IItKBRXYe}e0Tdr=Huf7da3$l4PdpwWDop%^}n;dD#K4s#DYA8SHZ
z&1!riV4W4R7R#C))JH1~axJ)RYnM$$lIR%6fIVA@zV{XVyx}C+a-Dt8Y9M)^KU0+H
zR4IUb2CJ{Hg>CuaXtD50jB(_Tcx=Z$^WYu2u5kubqmwp%drJ6
z?Fo40g!Qd<-l=TQxqHEOuPX0;^z7iX?Ke^a%XT<13TA^5`4Xcw6D@Ur&VT&CUe0d}
z1GjOVF1^L@>O)l@?bD~$wzgf(nxX1OGD8fEV?TdJcZc2KoUe|oP1#=$$7ee|xbY)A
zDZq+cuTpc(fFdj^=!;{k03C69lMQ(|>uhRfRu%+!k&YOi-3|1QKB
z
z?n?eq1XP>p-IM$Z^C;2L3itnbJZAip*Zo0aw2bs8@(s^~*8T9go!%dHcAz2lM;`yp
zD=7&xjFV$S&5uDaiScyD?B-i1ze`+CoRtz`Wn+Zls4&}MO{@N!ufrzjG$B79)Y2d3tBk&)TxUTw@QS0TEL_?njX|@vq?Uz(nBFK5Pq7*xj#u*R&i|?7+6#
z+|r_n#SW&LXhtheZdah{ZVoqwyT{D>MC3nkFF#N)xLi{p7J1jXlmVeb;cP5?e(=f#
zuT7fvjSbjS781v?7{)-X3*?>tq?)Yd)~|1{BDS(pqC
zC}~H#WXlkUW*H5CDOo<)#x7%RY)A;ShGhI5s*#cRDA8YgqG(HeKDx+#(ZQ?386dv!
zlXCO)w91~Vw4AmOcATuV653fa9R$fyK8ul%rG
z-wfS
zihugoZyr38Im?Zuh6@RcF~t1anQu7>#lPpb#}4cOA!EM11`%f*07RqOVkmX{p~KJ9
z^zP;K#|)$`^Rb{rnHGH{~>1(fawV0*Z#)}M`m8-?ZJV<+e}s9wE#
z)l&az?w^5{)`S(%MRzxdNqrs1n*-=jS^_jqE*5XDrA0+VE`5^*p3CuM<&dZEeCjoz
zR;uu_H9ZPZV|fQq`Cyw4nscrVwi!fE6ciMmX$!_hN7uF;jjKG)d2@aC4ropY)8etW=xJvni)8eHi`H$%#zn^WJ5NLc-rqk|u&&4Z6fD_m&JfSI1Bvb?b<*n&sfl0^t
z=HnmRl`XrFvMKB%9}>PaA`m-fK6a0(8=qPkWS5bb4=v?XcWi&hRY?O5HdulRi4?fN
zlsJ*N-0Qw+Yic@s0(2uy%F@ib;GjXt01Fmx5XbRo6+n|pP(&nodMoap^z{~q
ziEeaUT@Mxe3vJSfI6?uLND(CNr=#^W<1b}jzW58bIfyWTDle$mmS(|x-0|2UlX+9k
zQ^EX7Nw}?EzVoBfT(-LT|=9N@^hcn-_p&sqG
z&*oVs2JSU+N4ZD`FhCAWaS;>|wH2G*Id|?pa#@>tyxX`+4HyIArWDvVrX)2WAOQff
z0qyHu&-S@i^MS-+j--!pr4fPBj~_8({~e1bfcl0wI1kaoN>mJL6KUPQm5N7lB(ui1
zE-o%kq)&djzWJ}ob<-GfDlkB;F31j-VHKvQUGQ3sp`CwyGJk_i!y^sD0fqC@$9|jO
zOqN!r!8-p==F@ZVP=U$qSpY(gQ0)59P1&t@y?5rvg<}E+GB}26NYPp4f2YFQrQtot5mn3wu_qprZ=>Ig-$
zbW26Ws~IgY>}^5w`vTB(G`PTZaDiGBo5o(tp)qli|NeV(
z@H_=R8V39rt5J5YB2Ky?4eJJ#b`_iBe2ot~6%7mLt5t8Vwi^Jy7|jWXqa3amOIoRb
zOr}WVFP--DsS`1WpN%~)t3R!arKF^Q$e12KEqU36AWwnCBICpH4XCsfnyrHr>$I$4
z!DpKX$OKLWarN7nv@!uIA+~RNO)l$$w}p(;b>mx8pwYvu;dD_unryX_NhT8*Tj>BTrTTL&!?O+%Rv;b?B??gSzdp?6Uug9{
zd@V08Z$BdI?fpoCS$)t4mg4rT8Q_I}h`0d-vYZ^|dOB*Q^S|xqTV*vIg?@fVFSmMpaw0qtTRbx}
z({Pg?#{2`sc9)M5N$*N|4;^t$+QP?#mov
zGVC@I*lBVrOU-%2y!7%)fAKjpEFsgQc4{amtiHb95KQEwvf<(3T<9-Zm$xIew#P22
zc2Ix|App^>v6(3L_MCU0d3W##AB0M~3D00EWoKZqsJYT(#@w$Y_H7G22M~ApVFTRHMI_3be)Lkn#0F*V8Pq
zc}`Cjy$bE;FJ6H7p=0y#R>`}-m4(0F>%@P|?7fx{=R^uFdISRnZ2W_xQhD{YuR3t<
z{6yxu=4~JkeA;|(J6_nv#>Nvs&FuLA&PW^he@t(UwFFE8)|a!R{`E`K`i^ZnyE4$k
z;(749Ix|oi$c3QbEJ3b~D_kQsPz~fIUKym($a_7dJ?o+40*OLl^{=&oq$<#Q(yyrp
z{J-FAniyAw9tPbe&IhQ|a`DqFTVQGQ&Gq3!C2==4x{6EJwiPZ8zub-iXoUtkJiG{}
zPaR&}_fn8_z~(=;5lD-aPWD3z8PZS@AaUiomF!G8I}Mf>e~0g#BelA-5#`cj;O5>N
Xviia!U7SGha1wx#SCgwmn*{w2TRX*I
literal 0
HcmV?d00001
From 963bff9650983e8910f25b37219a26f5d4fea817 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Wed, 4 Nov 2020 09:07:11 +0100
Subject: [PATCH 426/940] shopfloor, zone picking: add a test to unload partial
moves
---
shopfloor/tests/test_zone_picking_base.py | 76 +++++++++++++++----
shopfloor/tests/test_zone_picking_start.py | 15 +++-
.../tests/test_zone_picking_unload_all.py | 67 ++++++++++++++++
...est_zone_picking_unload_set_destination.py | 26 +++----
4 files changed, 157 insertions(+), 27 deletions(-)
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index e6fdd4ec0c..2d4eb6904e 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -75,6 +75,17 @@ def setUpClassBaseData(cls, *args, **kwargs):
}
)
)
+ cls.zone_sublocation5 = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone sub-location 5",
+ "location_id": cls.zone_location.id,
+ "barcode": "ZONE_SUBLOCATION_5",
+ }
+ )
+ )
cls.packing_sublocation_a = (
cls.env["stock.location"]
.sudo()
@@ -123,6 +134,32 @@ def setUpClassBaseData(cls, *args, **kwargs):
}
)
)
+ cls.product_g = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product G",
+ "type": "product",
+ "default_code": "G",
+ "barcode": "G",
+ "weight": 3,
+ }
+ )
+ )
+ cls.product_h = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product H",
+ "type": "product",
+ "default_code": "H",
+ "barcode": "H",
+ "weight": 3,
+ }
+ )
+ )
products = (
cls.product_a
+ cls.product_b
@@ -130,6 +167,8 @@ def setUpClassBaseData(cls, *args, **kwargs):
+ cls.product_d
+ cls.product_e
+ cls.product_f
+ + cls.product_g
+ + cls.product_h
)
for product in products:
cls.env["stock.putaway.rule"].sudo().create(
@@ -140,31 +179,42 @@ def setUpClassBaseData(cls, *args, **kwargs):
}
)
+ # 1 product in a package available in zone_sublocation1
cls.picking1 = picking1 = cls._create_picking(lines=[(cls.product_a, 10)])
- cls.picking2 = picking2 = cls._create_picking(
- lines=[(cls.product_b, 10), (cls.product_c, 10)]
- )
- cls.picking3 = picking3 = cls._create_picking(lines=[(cls.product_d, 10)])
- cls.picking4 = picking4 = cls._create_picking(lines=[(cls.product_e, 10)])
- cls.picking5 = picking5 = cls._create_picking(
- lines=[(cls.product_b, 10), (cls.product_f, 10)]
- )
- cls.pickings = picking1 | picking2 | picking3 | picking4 | picking5
cls._fill_stock_for_moves(
picking1.move_lines, in_package=True, location=cls.zone_sublocation1
)
+ # 2 products with lots available in zone_sublocation2
+ cls.picking2 = picking2 = cls._create_picking(
+ lines=[(cls.product_b, 10), (cls.product_c, 10)]
+ )
cls._fill_stock_for_moves(
picking2.move_lines, in_lot=True, location=cls.zone_sublocation2
)
+ # 1 product (no package, no lot) available in zone_sublocation3
+ cls.picking3 = picking3 = cls._create_picking(lines=[(cls.product_d, 10)])
cls._fill_stock_for_moves(picking3.move_lines, location=cls.zone_sublocation3)
- cls._fill_stock_for_moves(
- picking5.move_lines, in_package=True, location=cls.zone_sublocation4
- )
+ # 1 product, available in zone_sublocation3 and zone_sublocation4
# Put product_e quantities in two different source locations to get
# two stock move lines (6 and 4 to satisfy 10 qties)
+ cls.picking4 = picking4 = cls._create_picking(lines=[(cls.product_e, 10)])
cls._update_qty_in_location(cls.zone_sublocation3, cls.product_e, 6)
cls._update_qty_in_location(cls.zone_sublocation4, cls.product_e, 4)
- # cls._fill_stock_for_moves(picking4.move_lines, location=cls.zone_sublocation3)
+ # 2 products in a package available in zone_sublocation4
+ cls.picking5 = picking5 = cls._create_picking(
+ lines=[(cls.product_b, 10), (cls.product_f, 10)]
+ )
+ cls._fill_stock_for_moves(
+ picking5.move_lines, in_package=True, location=cls.zone_sublocation4
+ )
+ # 2 products available in zone_sublocation5, but one is partially available
+ cls.picking6 = picking6 = cls._create_picking(
+ lines=[(cls.product_g, 6), (cls.product_h, 6)]
+ )
+ cls._update_qty_in_location(cls.zone_sublocation5, cls.product_g, 6)
+ cls._update_qty_in_location(cls.zone_sublocation5, cls.product_h, 3)
+
+ cls.pickings = picking1 | picking2 | picking3 | picking4 | picking5 | picking6
cls.pickings.action_assign()
# Some records not related at all to the processed move lines
cls.free_package = cls.env["stock.quant.package"].create(
diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py
index 2e1ecdc242..2019e9969b 100644
--- a/shopfloor/tests/test_zone_picking_start.py
+++ b/shopfloor/tests/test_zone_picking_start.py
@@ -83,8 +83,21 @@ def test_data_for_zone(self):
)
],
)
+ expected_sub5 = dict(
+ self.data.location(self.zone_sublocation5),
+ operation_types=[
+ dict(
+ op_type_data,
+ lines_count=2,
+ picking_count=1,
+ priority_lines_count=0,
+ priority_picking_count=0,
+ )
+ ],
+ )
self.assertEqual(
- zones_data, [expected_sub1, expected_sub2, expected_sub3, expected_sub4]
+ zones_data,
+ [expected_sub1, expected_sub2, expected_sub3, expected_sub4, expected_sub5],
)
def test_select_zone(self):
diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py
index 38e0632339..23ec7800e8 100644
--- a/shopfloor/tests/test_zone_picking_unload_all.py
+++ b/shopfloor/tests/test_zone_picking_unload_all.py
@@ -220,6 +220,73 @@ def test_set_destination_all_ok(self):
message=self.service.msg_store.buffer_complete(),
)
+ def test_set_destination_all_partial_qty_done_ok(self):
+ zone_location = self.zone_location
+ picking_type = self.picking6.picking_type_id
+ move_g = self.picking6.move_lines.filtered(
+ lambda m: m.product_id == self.product_g
+ )
+ move_h = self.picking6.move_lines.filtered(
+ lambda m: m.product_id == self.product_h
+ )
+ self.assertEqual(move_g.state, "assigned")
+ self.assertEqual(move_h.state, "partially_available")
+ move_line_g = move_g.move_line_ids
+ move_line_h = move_h.move_line_ids
+ another_package = self.env["stock.quant.package"].create(
+ {"name": "ANOTHER_PACKAGE"}
+ )
+ # set the destination package on lines
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line_g,
+ move_line_g.product_uom_qty,
+ self.free_package,
+ )
+ self.service._set_destination_package(
+ zone_location,
+ picking_type,
+ move_line_h,
+ move_line_h.product_uom_qty, # partial qty
+ another_package,
+ )
+ # set destination location for all lines in the buffer
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ # check data
+ # picking validated
+ picking_validated = self.picking6.backorder_ids
+ self.assertEqual(picking_validated.state, "done")
+ self.assertEqual(picking_validated.move_line_ids, move_line_g | move_line_h)
+ self.assertEqual(move_line_g.state, "done")
+ self.assertEqual(move_line_g.qty_done, 6)
+ self.assertEqual(move_line_h.state, "done")
+ self.assertEqual(move_line_h.qty_done, 3)
+ # current picking (backorder)
+ self.assertEqual(self.picking6.state, "confirmed")
+ self.assertEqual(self.picking6.move_lines.product_id, self.product_h)
+ self.assertEqual(self.picking6.move_lines.product_uom_qty, 3)
+ self.assertFalse(self.picking6.move_line_ids)
+ # buffer should be empty
+ buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
+ self.assertFalse(buffer_lines)
+ # check response
+ move_lines = self.service._find_location_move_lines(zone_location, picking_type)
+ self.assert_response_select_line(
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
+ message=self.service.msg_store.buffer_complete(),
+ )
+
def test_set_destination_all_location_not_allowed(self):
zone_location = self.zone_location
picking_type = self.picking1.picking_type_id
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
index 0e85617345..6108454386 100644
--- a/shopfloor/tests/test_zone_picking_unload_set_destination.py
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -13,21 +13,21 @@ class ZonePickingUnloadSetDestinationCase(ZonePickingCommonCase):
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
- cls.product_g = (
+ cls.product_z = (
cls.env["product.product"]
.sudo()
.create(
{
- "name": "Product G",
+ "name": "Product Z",
"type": "product",
- "default_code": "G",
- "barcode": "G",
+ "default_code": "Z",
+ "barcode": "Z",
"weight": 7,
}
)
)
- cls.picking_g = cls._create_picking(lines=[(cls.product_g, 40)])
- cls._update_qty_in_location(cls.zone_sublocation1, cls.product_g, 32)
+ cls.picking_z = cls._create_picking(lines=[(cls.product_z, 40)])
+ cls._update_qty_in_location(cls.zone_sublocation1, cls.product_z, 32)
def test_unload_set_destination_wrong_parameters(self):
zone_location = self.zone_location
@@ -322,10 +322,10 @@ def test_unload_set_destination_ok_buffer_not_empty(self):
def test_unload_set_destination_partially_available_backorder(self):
zone_location = self.zone_location
- picking_type = self.picking_g.picking_type_id
- self.assertEqual(self.picking_g.move_lines[0].product_uom_qty, 40)
- self.picking_g.action_assign()
- move_line = self.picking_g.move_line_ids
+ picking_type = self.picking_z.picking_type_id
+ self.assertEqual(self.picking_z.move_lines[0].product_uom_qty, 40)
+ self.picking_z.action_assign()
+ move_line = self.picking_z.move_line_ids
self.assertEqual(move_line.product_uom_qty, 32)
self.assertEqual(move_line.move_id.state, "partially_available")
packing_sublocation = (
@@ -359,11 +359,11 @@ def test_unload_set_destination_partially_available_backorder(self):
)
# check data
# move line has been moved to a new picking
- self.assertEqual(move_line.move_id.picking_id, self.picking_g.backorder_ids[0])
+ self.assertEqual(move_line.move_id.picking_id, self.picking_z.backorder_ids[0])
# the old picking contains a new line w/ the rest of the qty
# that couldn't be processed
- self.assertEqual(self.picking_g.move_lines[0].product_uom_qty, 8)
- self.assertEqual(self.picking_g.state, "confirmed")
+ self.assertEqual(self.picking_z.move_lines[0].product_uom_qty, 8)
+ self.assertEqual(self.picking_z.state, "confirmed")
# the line has been processed
self.assertEqual(move_line.location_dest_id, packing_sublocation)
self.assertEqual(move_line.move_id.state, "done")
From 98a228d2cf508fd8e524ed79d0f9fa1fd8e6bf48 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Wed, 4 Nov 2020 12:19:06 +0100
Subject: [PATCH 427/940] shopfloor, zone picking: be able to unload partial
moves
---
shopfloor/models/stock_move.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py
index 6c8f2abb4d..20473b6f26 100644
--- a/shopfloor/models/stock_move.py
+++ b/shopfloor/models/stock_move.py
@@ -44,6 +44,12 @@ def extract_and_action_done(self):
to first extract some move lines in a separate move, then validate it
with this method.
"""
+ # Put remaining qty to process from partially available moves
+ # in their own move (which will be then 'confirmed')
+ partial_moves = self.filtered(lambda m: m.state == "partially_available")
+ for partial_move in partial_moves:
+ partial_move.split_other_move_lines(partial_move.move_line_ids)
+ # Process assigned moves
moves = self.filtered(lambda m: m.state == "assigned")
if not moves:
return False
From dd5ec332660ecd799aa6594151d129988f070d10 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Mon, 9 Nov 2020 17:07:00 +0100
Subject: [PATCH 428/940] [FIX] shopfloor, zone picking: do not ask the user to
choose among lines
When scanning a source location to find the corresponding move line, if
the system find several corresponding lines (same product/package/lot),
no need to ask the user to choose the line among them: just take the
first (according to the current order).
---
shopfloor/services/zone_picking.py | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index e61992cf2b..b7fa75166b 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -420,7 +420,9 @@ def list_move_lines(self, zone_location_id, picking_type_id, order="priority"):
)
return self._response_for_select_line(zone_location, picking_type, move_lines)
- def _scan_source_location(self, zone_location, picking_type, location):
+ def _scan_source_location(
+ self, zone_location, picking_type, location, order="priority"
+ ):
"""Return the move line related to the scanned `location`.
The method tries to identify unambiguously a move line in the location
@@ -439,9 +441,10 @@ def _scan_source_location(self, zone_location, picking_type, location):
domain.append(("lot_id", "=", lot.id))
if package:
domain.append(("package_id", "=", package.id))
- move_line = self.env["stock.move.line"].search(domain)
- if len(move_line) == 1:
- return move_line
+ move_lines = self.env["stock.move.line"].search(domain)
+ sort_keys_func, reverse = self._sort_key_move_lines(order)
+ move_lines = move_lines.sorted(sort_keys_func, reverse=reverse)
+ return first(move_lines)
return False
def _scan_source_package(self, zone_location, picking_type, package, order):
@@ -498,7 +501,7 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit
message=self.msg_store.location_not_allowed()
)
move_line = self._scan_source_location(
- zone_location, picking_type, location
+ zone_location, picking_type, location, order=order
)
# if no move line, narrow the list of move lines on the scanned location
if not move_line:
From aa013dc1890f25acd131d14310caba16e84c86e1 Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Tue, 10 Nov 2020 08:43:18 +0000
Subject: [PATCH 429/940] shopfloor 13.0.1.3.1
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 0adc6d0d30..0e5181d14e 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.3.0",
+ "version": "13.0.1.3.1",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From f958551f24c8ad5c5588a79ba74ab1527965e0ee Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Tue, 10 Nov 2020 09:25:45 +0000
Subject: [PATCH 430/940] shopfloor 13.0.1.3.2
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 0e5181d14e..2e74a0ffc1 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.3.1",
+ "version": "13.0.1.3.2",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From 353761aacf760ec8e9d01a326fe22b103f40805c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Tue, 10 Nov 2020 16:55:19 +0100
Subject: [PATCH 431/940] [FIX] shopfloor, zone picking: do not process line
processed by someone else
First user scans the source location 'Zone sub-location 1' containing
only one move line, then processes the next step 'set_line_destination'.
The second user scans the same source location, and should not find any line.
+ since 9021c85c7085f883756ff64b797950a2192c3a5a has been merged, if no move
line is found when scanning the source location, the message returned has to
say that the location is empty.
---
shopfloor/actions/message.py | 6 ---
shopfloor/services/zone_picking.py | 9 +++-
shopfloor/tests/test_zone_picking_base.py | 14 ++++++
.../tests/test_zone_picking_select_line.py | 43 +++++++++++++++++--
4 files changed, 61 insertions(+), 11 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index db9850e8ad..90cdf2f174 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -175,12 +175,6 @@ def no_pack_in_location(self, location):
"body": _("Location %s doesn't contain any package.") % location.name,
}
- def several_lines_in_location(self, location):
- return {
- "message_type": "warning",
- "body": _("Several lines found in %s, please scan one.") % location.name,
- }
-
def several_packs_in_location(self, location):
return {
"message_type": "warning",
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index b7fa75166b..e7183208b4 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -434,7 +434,12 @@ def _scan_source_location(
package = quants.package_id
if len(product) > 1 or len(lot) > 1 or len(package) > 1:
return False
- domain = [("location_id", "=", location.id)]
+ domain = [
+ ("location_id", "=", location.id),
+ "|",
+ ("shopfloor_user_id", "=", False),
+ ("shopfloor_user_id", "=", self.env.uid),
+ ]
if product:
domain.append(("product_id", "=", product.id))
if lot:
@@ -508,7 +513,7 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit
response = self.list_move_lines(location.id, picking_type.id)
return self._response(
base_response=response,
- message=self.msg_store.several_lines_in_location(location),
+ message=self.msg_store.location_empty(location),
)
package = search.package_from_scan(barcode)
if package:
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 2d4eb6904e..2f7661ba24 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -12,6 +12,20 @@ def setUpClassVars(cls, *args, **kwargs):
cls.wh = cls.profile.warehouse_id
cls.picking_type = cls.menu.picking_type_ids
+ @classmethod
+ def setUpClassUsers(cls):
+ super().setUpClassUsers()
+ Users = cls.env["res.users"].sudo().with_context(no_reset_password=True)
+ cls.stock_user2 = Users.create(
+ {
+ "name": "Paul Posichon",
+ "login": "paulposichon",
+ "email": "paul.posichon@example.com",
+ "notification_type": "inbox",
+ "groups_id": [(6, 0, [cls.env.ref("stock.group_stock_user").id])],
+ }
+ )
+
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
super().setUpClassBaseData(*args, **kwargs)
diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py
index 30818b6c34..20851d25e0 100644
--- a/shopfloor/tests/test_zone_picking_select_line.py
+++ b/shopfloor/tests/test_zone_picking_select_line.py
@@ -118,9 +118,7 @@ def test_scan_source_barcode_location_several_move_lines(self):
zone_location=self.zone_sublocation2,
picking_type=self.picking_type,
move_lines=move_lines,
- message=self.service.msg_store.several_lines_in_location(
- self.zone_sublocation2
- ),
+ message=self.service.msg_store.location_empty(self.zone_sublocation2),
)
def test_scan_source_barcode_package(self):
@@ -293,6 +291,45 @@ def test_scan_source_barcode_not_found(self):
message=self.service.msg_store.barcode_not_found(),
)
+ def test_scan_source_multi_users(self):
+ """First user scans the source location 'Zone sub-location 1' containing
+ only one move line, then processes the next step 'set_line_destination'.
+
+ The second user scans the same source location, and should not find any line.
+ """
+ # The first user starts to process the only line available
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ # - scan source
+ response = self.service.scan_source(
+ zone_location.id, picking_type.id, self.zone_sublocation1.barcode,
+ )
+ move_line = self.picking1.move_line_ids
+ self.assertEqual(response["next_state"], "set_line_destination")
+ # - set destination
+ self.service.set_destination(
+ zone_location.id,
+ picking_type.id,
+ move_line.id,
+ self.free_package.name,
+ move_line.product_uom_qty,
+ )
+ self.assertEqual(move_line.shopfloor_user_id, self.env.user)
+ # The second user scans the same source location
+ env = self.env(user=self.stock_user2)
+ with self.work_on_services(
+ env=env, menu=self.menu, profile=self.profile
+ ) as work:
+ service = work.component(usage="zone_picking")
+ response = service.scan_source(
+ zone_location.id, picking_type.id, self.zone_sublocation1.barcode,
+ )
+ self.assertEqual(response["next_state"], "select_line")
+ self.assertEqual(
+ response["message"],
+ self.service.msg_store.location_empty(self.zone_sublocation1),
+ )
+
def test_prepare_unload_wrong_parameters(self):
zone_location = self.zone_location
picking_type = self.picking1.picking_type_id
From 65280beb39397b63a9afeef15edc2680ff2fec79 Mon Sep 17 00:00:00 2001
From: oca-travis
Date: Thu, 12 Nov 2020 12:57:32 +0000
Subject: [PATCH 432/940] [UPD] Update shopfloor.pot
---
shopfloor/i18n/shopfloor.pot | 6 ------
1 file changed, 6 deletions(-)
diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot
index 0ffe4b6c66..1a4809fa37 100644
--- a/shopfloor/i18n/shopfloor.pot
+++ b/shopfloor/i18n/shopfloor.pot
@@ -929,12 +929,6 @@ msgstr ""
msgid "Sequence"
msgstr ""
-#. module: shopfloor
-#: code:addons/shopfloor/actions/message.py:0
-#, python-format
-msgid "Several lines found in %s, please scan one."
-msgstr ""
-
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
From c44da4b131659c6c1dd26140282d2ffe16298181 Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Thu, 12 Nov 2020 13:22:38 +0000
Subject: [PATCH 433/940] shopfloor 13.0.1.3.3
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 2e74a0ffc1..d71334a4a3 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.3.2",
+ "version": "13.0.1.3.3",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From 3a8c7c754a417c7e7bd062de7800f3fc76045555 Mon Sep 17 00:00:00 2001
From: hparfr
Date: Tue, 13 Oct 2020 12:28:20 +0200
Subject: [PATCH 434/940] shopfloor: api docs fix api_key_demo not found
When the key is not found, it should not fail.
---
shopfloor/services/service.py | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 5c39cf446c..a5252f2239 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -265,9 +265,10 @@ def _get_openapi_default_parameters(self):
defaults = super()._get_openapi_default_parameters()
# Normal users can't read an API key, ignore it using sudo() only
# because it's a demo key.
- demo_api_key = self.env.ref(
- "shopfloor.api_key_demo", raise_if_not_found=False
- ).sudo()
+ demo_api_key = self.env.ref("shopfloor.api_key_demo", raise_if_not_found=False)
+ if demo_api_key:
+ demo_api_key = demo_api_key.sudo()
+
service_params = [
{
"name": "API-KEY",
From 2536fb5605be10a50a42fd7f97205c8b49c570d8 Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Fri, 13 Nov 2020 11:31:29 +0000
Subject: [PATCH 435/940] shopfloor 13.0.1.3.4
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index d71334a4a3..8c428c7dad 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.3.3",
+ "version": "13.0.1.3.4",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From 8d7061e2fcc61ba9fa33e6ce6278eb517c341d25 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 13 Nov 2020 16:42:23 +0100
Subject: [PATCH 436/940] shopfloor: zone_picking fix group zone lines
Zone lines counters should be grouped by operation type.
itertools.groupby was failing somehow.
In any case the new version is more eficient
as we loop less on lines.
---
shopfloor/services/zone_picking.py | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index e7183208b4..3475518b58 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1,7 +1,7 @@
# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import functools
-from itertools import groupby
+from collections import defaultdict
from odoo.fields import first
from odoo.tools.float_utils import float_compare, float_is_zero
@@ -265,19 +265,19 @@ def _data_for_select_zone(self, zones):
res = []
for zone in zones:
zone_data = self.data.location(zone)
- zone_lines = self._zone_lines(zone).sorted(
- key=lambda x: x.picking_id.picking_type_id
- )
+ zone_lines = self._zone_lines(zone)
if not zone_lines:
continue
+ lines_by_op_type = defaultdict(list)
+ for line in zone_lines:
+ lines_by_op_type[line.picking_id.picking_type_id].append(line)
+
zone_data["operation_types"] = []
- for picking_type, lines in groupby(
- zone_lines, lambda line: line.picking_id.picking_type_id
- ):
- op_type = self.data.picking_type(picking_type)
- op_type.update(self._counters_for_zone_lines(list(lines)))
- zone_data["operation_types"].append(op_type)
+ for picking_type, lines in lines_by_op_type.items():
+ op_type_data = self.data.picking_type(picking_type)
+ op_type_data.update(self._counters_for_zone_lines(lines))
+ zone_data["operation_types"].append(op_type_data)
res.append(zone_data)
return res
From 616575a2445dc3793cda0b18574ddf5ad5e717ab Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Tue, 17 Nov 2020 14:04:50 +0000
Subject: [PATCH 437/940] shopfloor 13.0.1.3.5
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 8c428c7dad..32de430245 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.3.4",
+ "version": "13.0.1.3.5",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From de7a8fb455dc4b47c0c555624482c65514f80926 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 17 Nov 2020 17:06:56 +0100
Subject: [PATCH 438/940] zone_picking: fix line sorting by schedule date
---
shopfloor/services/zone_picking.py | 27 ++++---
.../tests/test_zone_picking_select_line.py | 79 ++++++++++++++++++-
2 files changed, 91 insertions(+), 15 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 3475518b58..ff0cece34b 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -317,22 +317,25 @@ def _find_location_move_lines(
locations, picking_type, package, product, lot
)
)
- sort_keys_func, reverse = self._sort_key_move_lines(order)
- move_lines = move_lines.sorted(sort_keys_func, reverse=reverse)
+ sort_keys_func = self._sort_key_move_lines(order)
+ move_lines = move_lines.sorted(sort_keys_func)
return move_lines
@staticmethod
def _sort_key_move_lines(order):
- """Return a `(sort_keys_func, reverse)` tuple for move lines."""
+ """Return a sorting function to order lines."""
+
if order == "priority":
- return lambda line: line.move_id.priority or "", True
+ # make prority negative to keep sorting ascending
+ return lambda line: (
+ -int(line.move_id.priority or "0"),
+ line.move_id.date_expected,
+ )
elif order == "location":
- return (
- lambda line: (
- line.location_id.shopfloor_picking_sequence or "",
- line.location_id.name,
- ),
- False,
+ return lambda line: (
+ line.location_id.shopfloor_picking_sequence or "",
+ line.location_id.name,
+ line.move_id.date_expected,
)
def _find_buffer_move_lines_domain(
@@ -447,8 +450,8 @@ def _scan_source_location(
if package:
domain.append(("package_id", "=", package.id))
move_lines = self.env["stock.move.line"].search(domain)
- sort_keys_func, reverse = self._sort_key_move_lines(order)
- move_lines = move_lines.sorted(sort_keys_func, reverse=reverse)
+ sort_keys_func = self._sort_key_move_lines(order)
+ move_lines = move_lines.sorted(sort_keys_func)
return first(move_lines)
return False
diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py
index 20851d25e0..bdc44beb5a 100644
--- a/shopfloor/tests/test_zone_picking_select_line.py
+++ b/shopfloor/tests/test_zone_picking_select_line.py
@@ -1,5 +1,7 @@
# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo import fields
+
from .test_zone_picking_base import ZonePickingCommonCase
@@ -12,12 +14,65 @@ class ZonePickingSelectLineCase(ZonePickingCommonCase):
"""
+ def test_list_move_lines_order(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ self.zone_sublocation2.name = "AAA " + self.zone_sublocation2.name
+
+ # Test by location
+ today = fields.Datetime.today()
+ future = fields.Datetime.add(
+ fields.Datetime.end_of(fields.Datetime.today(), "day"), days=2
+ )
+ # change date to lines in the same location
+ move1 = self.picking2.move_lines[0]
+ move1.write({"date_expected": today})
+ move1_line = move1.move_line_ids[0]
+ move2 = self.picking2.move_lines[1]
+ move2.write({"date_expected": future})
+ move2_line = move2.move_line_ids[0]
+
+ move_lines = self.service._find_location_move_lines(
+ zone_location, picking_type, order="location"
+ )
+ order_mapping = {line: i for i, line in enumerate(move_lines)}
+ self.assertTrue(order_mapping[move1_line] < order_mapping[move2_line])
+
+ # swap dates
+ move2.write({"date_expected": today})
+ move1.write({"date_expected": future})
+ move_lines = self.service._find_location_move_lines(
+ zone_location, picking_type, order="location"
+ )
+ order_mapping = {line: i for i, line in enumerate(move_lines)}
+ self.assertTrue(order_mapping[move1_line] > order_mapping[move2_line])
+
+ # Test by priority
+ self.picking2.move_lines.write({"priority": "0"})
+ (self.pickings - self.picking2).move_lines.write({"priority": "2"})
+ move_lines = self.service._find_location_move_lines(
+ zone_location, picking_type, order="priority"
+ )
+ order_mapping = {line: i for i, line in enumerate(move_lines)}
+ # picking2 lines stay at the end as they are low priority
+ # but move1_line comes before the other
+ self.assertTrue(order_mapping[move1_line] > len(move_lines) - 4)
+ self.assertTrue(order_mapping[move2_line] > len(move_lines) - 3)
+ # swap dates again
+ move2.write({"date_expected": future})
+ move1.write({"date_expected": today})
+ # and increase priority
+ self.picking2.move_lines.write({"priority": "3"})
+ move_lines = self.service._find_location_move_lines(
+ zone_location, picking_type, order="priority"
+ )
+ order_mapping = {line: i for i, line in enumerate(move_lines)}
+ self.assertEqual(order_mapping[move1_line], 0)
+ self.assertEqual(order_mapping[move2_line], 1)
+
def test_list_move_lines_order_by_location(self):
zone_location = self.zone_location
picking_type = self.picking1.picking_type_id
- # Ensure that the second location is ordered before the first one
- # to avoid "false-positive" checks
- self.zone_sublocation2.name = "a " + self.zone_sublocation2.name
response = self.service.dispatch(
"list_move_lines",
params={
@@ -33,6 +88,24 @@ def test_list_move_lines_order_by_location(self):
response, zone_location, picking_type, move_lines,
)
+ def test_list_move_lines_order_by_priority(self):
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "list_move_lines",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "order": "priority",
+ },
+ )
+ move_lines = self.service._find_location_move_lines(
+ zone_location, picking_type, order="priority"
+ )
+ self.assert_response_select_line(
+ response, zone_location, picking_type, move_lines,
+ )
+
def test_scan_source_wrong_parameters(self):
zone_location = self.zone_location
picking_type = self.picking1.picking_type_id
From 5beeedef483eb94ea9b0e2a6899ede9168d92d16 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 17 Nov 2020 17:58:42 +0100
Subject: [PATCH 439/940] cluster_picking: fix line sorting by schedule date
---
shopfloor/services/cluster_picking.py | 1 +
.../tests/test_cluster_picking_select.py | 38 +++++++++++++++++++
2 files changed, 39 insertions(+)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 33c3da1d3e..9e91d91df0 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -324,6 +324,7 @@ def _sort_key_lines(line):
line.location_id.shopfloor_picking_sequence or "",
line.location_id.name,
-int(line.move_id.priority or 1),
+ line.move_id.date_expected,
line.move_id.sequence,
line.move_id.id,
line.id,
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index 48c6d9f60e..e1d7dc983d 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -1,6 +1,8 @@
# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo import fields
+
from .test_cluster_picking_base import ClusterPickingCommonCase
@@ -263,6 +265,42 @@ def setUpClassBaseData(cls, *args, **kwargs):
[[cls.BatchProduct(product=cls.product_a, quantity=1)]]
)
cls._simulate_batch_selected(cls.batch, in_package=True)
+ cls.batch2 = cls._create_picking_batch(
+ [
+ [cls.BatchProduct(product=cls.product_a, quantity=1)],
+ [cls.BatchProduct(product=cls.product_a, quantity=1)],
+ [cls.BatchProduct(product=cls.product_b, quantity=1)],
+ [cls.BatchProduct(product=cls.product_b, quantity=1)],
+ ]
+ )
+ cls._simulate_batch_selected(cls.batch2, in_package=True)
+
+ def test_lines_order(self):
+ batch = self.batch2
+ picking1 = batch.picking_ids[0]
+ today = fields.Datetime.today()
+ future = fields.Datetime.add(
+ fields.Datetime.end_of(fields.Datetime.today(), "day"), days=2
+ )
+ # Change dates
+ move1 = picking1.move_lines[0]
+ move1_line = move1.move_line_ids[0]
+ move1.write({"date_expected": today})
+ (batch.picking_ids.move_lines - move1).write({"date_expected": future})
+
+ move_lines = self.service._lines_for_picking_batch(batch)
+ order_mapping = {line: i for i, line in enumerate(move_lines)}
+
+ # Today line comes first
+ self.assertEqual(order_mapping[move1_line], 0)
+ # swap dates
+ move1.write({"date_expected": future})
+ (batch.picking_ids.move_lines - move1).write({"date_expected": today})
+
+ move_lines = self.service._lines_for_picking_batch(batch)
+ order_mapping = {line: i for i, line in enumerate(move_lines)}
+ self.assertEqual(order_mapping[move1_line], len(move_lines) - 1)
+ # TODO: we should test all the combo of keys affecting sorting.
def test_confirm_start_ok(self):
"""User confirms she starts the selected picking batch (happy path)"""
From 2fc798e66ccaa288b3d8567d65aba38372429e26 Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Thu, 19 Nov 2020 11:04:08 +0000
Subject: [PATCH 440/940] shopfloor 13.0.1.3.6
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 32de430245..395cffed92 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.3.5",
+ "version": "13.0.1.3.6",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From ade45552b0c9791075c5cbf72ed7fa550b154316 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 19 Nov 2020 11:22:52 +0100
Subject: [PATCH 441/940] shopfloor: zone_picking test multiple line on scan
source loc
---
shopfloor/tests/common.py | 5 +-
.../tests/test_zone_picking_select_line.py | 49 +++++++++++++++++++
2 files changed, 53 insertions(+), 1 deletion(-)
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index e3c7a056f6..d298be103e 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -302,7 +302,10 @@ def _fill_stock_for_moves(
product_locations = {}
package = None
if in_package:
- package = cls.env["stock.quant.package"].create({})
+ if isinstance(in_package, models.BaseModel):
+ package = in_package
+ else:
+ package = cls.env["stock.quant.package"].create({})
for move in moves:
key = (move.product_id, location or move.location_id)
product_locations.setdefault(key, 0)
diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py
index bdc44beb5a..7c5eadeddd 100644
--- a/shopfloor/tests/test_zone_picking_select_line.py
+++ b/shopfloor/tests/test_zone_picking_select_line.py
@@ -170,6 +170,55 @@ def test_scan_source_barcode_location_one_move_line(self):
move_line=move_line,
)
+ def test_scan_source_barcode_location_two_move_lines_same_product(self):
+ """Scan source: scanned location 'Zone sub-location 1' contains two lines.
+
+ Lines have the same product/package/lot,
+ they get processed one after the other,
+ next step 'set_line_destination' expected.
+ """
+ package = self.picking1.move_line_ids.mapped("package_id")[0]
+ new_picking = self._create_picking(lines=[(self.product_a, 20)])
+ self._fill_stock_for_moves(
+ new_picking.move_lines, in_package=package, location=self.zone_sublocation1
+ )
+ new_picking.action_assign()
+ zone_location = self.zone_location
+ picking_type = self.picking1.picking_type_id
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.zone_sublocation1.barcode,
+ },
+ )
+ move_line = self.picking1.move_line_ids
+ self.assert_response_set_line_destination(
+ response,
+ zone_location=self.zone_location,
+ picking_type=self.picking_type,
+ move_line=move_line,
+ )
+ # first line done
+ move_line.qty_done = move_line.product_uom_qty
+ # get the next one
+ response = self.service.dispatch(
+ "scan_source",
+ params={
+ "zone_location_id": zone_location.id,
+ "picking_type_id": picking_type.id,
+ "barcode": self.zone_sublocation1.barcode,
+ },
+ )
+ move_line = new_picking.move_line_ids
+ self.assert_response_set_line_destination(
+ response,
+ zone_location=self.zone_location,
+ picking_type=self.picking_type,
+ move_line=move_line,
+ )
+
def test_scan_source_barcode_location_several_move_lines(self):
"""Scan source: scanned location 'Zone sub-location 2' contains two
move lines, next step 'select_line' expected with the list of these
From 0856268628c8781745518c1ff828b21a1badb51d Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 19 Nov 2020 11:26:36 +0100
Subject: [PATCH 442/940] shopfloor: fix zone_picking._scan_source_location
Lines from source location should respect the same domain
as the one used to retrieve all available lines (eg: not done lines).
---
shopfloor/services/zone_picking.py | 41 +++++++++++++++++-------------
1 file changed, 24 insertions(+), 17 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index ff0cece34b..5ee98f00a2 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -282,7 +282,13 @@ def _data_for_select_zone(self, zones):
return res
def _find_location_move_lines_domain(
- self, locations, picking_type=None, package=None, product=None, lot=None
+ self,
+ locations,
+ picking_type=None,
+ package=None,
+ product=None,
+ lot=None,
+ match_user=False,
):
domain = [
("location_id", "child_of", locations.ids),
@@ -300,6 +306,12 @@ def _find_location_move_lines_domain(
domain += [("product_id", "=", product.id)]
if lot:
domain += [("lot_id", "=", lot.id)]
+ if match_user:
+ domain += [
+ "|",
+ ("shopfloor_user_id", "=", False),
+ ("shopfloor_user_id", "=", self.env.uid),
+ ]
return domain
def _find_location_move_lines(
@@ -310,11 +322,12 @@ def _find_location_move_lines(
product=None,
lot=None,
order="priority",
+ match_user=False,
):
"""Find lines that potentially need work in given locations."""
move_lines = self.env["stock.move.line"].search(
self._find_location_move_lines_domain(
- locations, picking_type, package, product, lot
+ locations, picking_type, package, product, lot, match_user=match_user
)
)
sort_keys_func = self._sort_key_move_lines(order)
@@ -437,21 +450,15 @@ def _scan_source_location(
package = quants.package_id
if len(product) > 1 or len(lot) > 1 or len(package) > 1:
return False
- domain = [
- ("location_id", "=", location.id),
- "|",
- ("shopfloor_user_id", "=", False),
- ("shopfloor_user_id", "=", self.env.uid),
- ]
- if product:
- domain.append(("product_id", "=", product.id))
- if lot:
- domain.append(("lot_id", "=", lot.id))
- if package:
- domain.append(("package_id", "=", package.id))
- move_lines = self.env["stock.move.line"].search(domain)
- sort_keys_func = self._sort_key_move_lines(order)
- move_lines = move_lines.sorted(sort_keys_func)
+ move_lines = self._find_location_move_lines(
+ location,
+ picking_type=picking_type,
+ product=product,
+ package=package,
+ lot=lot,
+ match_user=True,
+ )
+ if move_lines:
return first(move_lines)
return False
From 9bcd3f635f3a58efd88092c33ffa75fc766bf898 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Sat, 21 Nov 2020 11:12:50 +0100
Subject: [PATCH 443/940] shopfloor 13.0.1.3.7
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 395cffed92..79026c348b 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.3.6",
+ "version": "13.0.1.3.7",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From 6404e60ffc43e3089e29314116ea4e4944213264 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 25 Nov 2020 10:43:03 +0100
Subject: [PATCH 444/940] shopfloor: add service options via menu conf
---
shopfloor/__manifest__.py | 3 ++-
shopfloor/models/shopfloor_menu.py | 13 +++++++++++++
shopfloor/services/service.py | 18 ++++++++++++++++++
3 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 79026c348b..4f6dfa30d2 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -17,8 +17,9 @@
"depends": [
"stock",
"stock_picking_batch",
- "base_rest",
"base_jsonify",
+ "base_rest",
+ "base_sparse_field",
"auth_api_key",
# OCA / stock-logistics-warehouse
"stock_picking_completion_info",
diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py
index c2a351282c..e398fe32cf 100644
--- a/shopfloor/models/shopfloor_menu.py
+++ b/shopfloor/models/shopfloor_menu.py
@@ -2,6 +2,8 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, api, exceptions, fields, models
+from odoo.addons.base_sparse_field.models.fields import Serialized
+
class ShopfloorMenu(models.Model):
_name = "shopfloor.menu"
@@ -33,6 +35,17 @@ class ShopfloorMenu(models.Model):
)
scenario = fields.Selection(selection="_selection_scenario", required=True)
+ # TODO: `options` field allows to provide custom options for the scenario,
+ # (or for any other kind of service).
+ # Developers should probably have a way to register scenario and their options
+ # which will be computed in this field at the end.
+ # This would allow to get rid of hardcoded settings like
+ # `_scenario_allowing_create_moves` or `_scenario_allowing_unreserve_other_moves`.
+ # For now is not included in any view as it should be customizable by scenario.
+ # Maybe we can have a wizard accessible via a button on the menu tree view.
+ # There's no automation here. Developers are responsible for their usage
+ # and/or their exposure to the scenario api.
+ options = Serialized(default={})
move_create_is_possible = fields.Boolean(compute="_compute_move_create_is_possible")
# only available for some scenarios, move_create_is_possible defines if the option
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index a5252f2239..603c55d90c 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -414,6 +414,24 @@ def _work_ctx_get_menu_id(self, rec_id):
def _work_ctx_get_profile_id(self, rec_id):
return "profile", self.env["shopfloor.profile"].browse(rec_id).exists()
+ _options = {}
+
+ @property
+ def options(self):
+ """Compute options for current service.
+
+ If the service has a menu, options coming from the menu are injected.
+ """
+ if self._options:
+ return self._options
+
+ options = {}
+ if self._requires_header_menu and getattr(self.work, "menu", None):
+ options = self.work.menu.options or {}
+ options.update(getattr(self.work, "options", {}))
+ self._options = options
+ return self._options
+
class BaseShopfloorProcess(AbstractComponent):
"""Base class for process rest service"""
From eeb39b931274349b10b0d61b6df3c3a7019b5f93 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Wed, 25 Nov 2020 10:44:24 +0100
Subject: [PATCH 445/940] shopfloor: checkout option to turn off 'no_package'
---
shopfloor/services/checkout.py | 13 +++++
shopfloor/tests/test_checkout_no_package.py | 47 +++++++++++++------
shopfloor/tests/test_checkout_select_line.py | 12 +++++
.../test_checkout_select_package_base.py | 16 ++++---
4 files changed, 67 insertions(+), 21 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 88ff30d3c9..fe45bd74a1 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -1,5 +1,8 @@
# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from werkzeug.exceptions import BadRequest
+
from odoo import _
from odoo.addons.base_rest.components.service import to_int
@@ -73,6 +76,9 @@ def _response_for_select_package(self, picking, lines, message=None):
"selected_move_lines": self._data_for_move_lines(lines.sorted()),
"picking": self.data.picking(picking),
"packing_info": self._data_for_packing_info(picking),
+ "no_package_enabled": not self.options.get(
+ "checkout:disable_no_package"
+ ),
},
message=message,
)
@@ -758,6 +764,8 @@ def no_package(self, picking_id, selected_line_ids):
Transitions:
* select_line: goes back to selection of lines to work on next lines
"""
+ 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)
if message:
@@ -1180,6 +1188,11 @@ def _states(self):
"select_package": dict(
self._schema_selected_lines,
packing_info={"type": "string", "nullable": True},
+ no_package_enabled={
+ "type": "boolean",
+ "nullable": True,
+ "required": False,
+ },
),
"change_quantity": self._schema_selected_lines,
"select_dest_package": self._schema_select_package,
diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py
index 7242077825..e60a67ecfc 100644
--- a/shopfloor/tests/test_checkout_no_package.py
+++ b/shopfloor/tests/test_checkout_no_package.py
@@ -1,37 +1,44 @@
# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+import werkzeug
+
from .test_checkout_base import CheckoutCommonCase
from .test_checkout_select_package_base import CheckoutSelectPackageMixin
class CheckoutNoPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin):
- def test_no_package_ok(self):
- picking = self._create_picking(
+ @classmethod
+ def setUpClassBaseData(cls):
+ super().setUpClassBaseData()
+ cls.picking = picking = cls._create_picking(
lines=[
- (self.product_a, 10),
- (self.product_b, 10),
- (self.product_c, 10),
- (self.product_d, 10),
+ (cls.product_a, 10),
+ (cls.product_b, 10),
+ (cls.product_c, 10),
+ (cls.product_d, 10),
]
)
- pack1_moves = picking.move_lines[:3]
- pack2_moves = picking.move_lines[3:]
+ cls.pack1_moves = pack1_moves = picking.move_lines[:3]
+ cls.pack2_moves = pack2_moves = picking.move_lines[3:]
# put in 2 packs, for this test, we'll work on pack1
- self._fill_stock_for_moves(pack1_moves)
- self._fill_stock_for_moves(pack2_moves)
+ cls._fill_stock_for_moves(pack1_moves)
+ cls._fill_stock_for_moves(pack2_moves)
picking.action_assign()
- move_line1, move_line2, move_line3 = pack1_moves.move_line_ids
+ def test_no_package_ok(self):
+ move_line1, move_line2, move_line3 = self.pack1_moves.move_line_ids
selected_lines = move_line1 + move_line2
# we'll put only the first 2 lines (product A and B) w/ no package
move_line1.qty_done = move_line1.product_uom_qty
move_line2.qty_done = move_line2.product_uom_qty
move_line3.qty_done = 0
-
response = self.service.dispatch(
"no_package",
- params={"picking_id": picking.id, "selected_line_ids": selected_lines.ids},
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": selected_lines.ids,
+ },
)
self.assertRecordValues(
@@ -48,9 +55,21 @@ def test_no_package_ok(self):
response,
# go pack to the screen to select lines to put in packages
next_state="select_line",
- data={"picking": self._stock_picking_data(picking)},
+ data={"picking": self._stock_picking_data(self.picking)},
message={
"message_type": "success",
"body": "Product(s) processed as raw product(s)",
},
)
+
+ def test_no_package_disabled(self):
+ self.service.work.options = {"checkout:disable_no_package": True}
+ with self.assertRaises(werkzeug.exceptions.BadRequest) as err:
+ self.service.dispatch(
+ "no_package",
+ params={
+ "picking_id": self.picking.id,
+ "selected_line_ids": self.pack1_moves.move_line_ids.ids,
+ },
+ )
+ self.assertEqual(err.name, "`checkout.no_package` endpoint is not enabled")
diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py
index 477fed1f3f..2218060026 100644
--- a/shopfloor/tests/test_checkout_select_line.py
+++ b/shopfloor/tests/test_checkout_select_line.py
@@ -31,6 +31,18 @@ def test_select_line_package_ok(self):
)
self._assert_selected(response, selected_lines)
+ def test_select_line_no_package_disabled(self):
+ selected_lines = self.moves_pack.move_line_ids
+ self.service.work.options = {"checkout:disable_no_package": True}
+ response = self.service.dispatch(
+ "select_line",
+ params={
+ "picking_id": self.picking.id,
+ "package_id": selected_lines.package_id.id,
+ },
+ )
+ self._assert_selected(response, selected_lines, no_package_enabled=False)
+
def test_select_line_move_line_package_ok(self):
selected_lines = self.moves_pack.move_line_ids
# When we select a single line but the line is part of a package,
diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py
index 151beb76e2..f361b26fd8 100644
--- a/shopfloor/tests/test_checkout_select_package_base.py
+++ b/shopfloor/tests/test_checkout_select_package_base.py
@@ -4,7 +4,12 @@
class CheckoutSelectPackageMixin:
def _assert_selected_response(
- self, response, selected_lines, message=None, packing_info=False
+ self,
+ response,
+ selected_lines,
+ message=None,
+ packing_info=False,
+ no_package_enabled=True,
):
picking = selected_lines.mapped("picking_id")
self.assert_response(
@@ -16,6 +21,7 @@ def _assert_selected_response(
],
"picking": self._picking_summary_data(picking),
"packing_info": picking.shopfloor_packing_info if packing_info else "",
+ "no_package_enabled": no_package_enabled,
},
message=message,
)
@@ -41,9 +47,7 @@ def _assert_selected_qties(
response, selected_lines, message=message, packing_info=packing_info
)
- def _assert_selected(
- self, response, selected_lines, message=None, packing_info=False
- ):
+ def _assert_selected(self, response, selected_lines, message=None, **kw):
picking = selected_lines.mapped("picking_id")
unselected_lines = picking.move_line_ids - selected_lines
for line in selected_lines:
@@ -54,6 +58,4 @@ def _assert_selected(
)
for line in unselected_lines:
self.assertEqual(line.qty_done, 0)
- self._assert_selected_response(
- response, selected_lines, message=message, packing_info=packing_info
- )
+ self._assert_selected_response(response, selected_lines, message=message, **kw)
From d6244d5176abc63444f829456b6dc26a0e99a7c8 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 20 Nov 2020 14:22:39 +0100
Subject: [PATCH 446/940] shopfloor: zone_picking add counters per zone
---
shopfloor/services/zone_picking.py | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 5ee98f00a2..979232c8b3 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -197,7 +197,8 @@ def _data_for_select_picking_type(self, zone_location, picking_types):
for datum in data["picking_types"]:
picking_type = self.env["stock.picking.type"].browse(datum["id"])
zone_lines = self._picking_type_zone_lines(zone_location, picking_type)
- datum.update(self._counters_for_zone_lines(zone_lines))
+ counters = self._counters_for_zone_lines(zone_lines)
+ datum.update(counters)
return data
def _counters_for_zone_lines(self, zone_lines):
@@ -273,11 +274,15 @@ def _data_for_select_zone(self, zones):
lines_by_op_type[line.picking_id.picking_type_id].append(line)
zone_data["operation_types"] = []
-
+ zone_counters = defaultdict(int)
for picking_type, lines in lines_by_op_type.items():
op_type_data = self.data.picking_type(picking_type)
- op_type_data.update(self._counters_for_zone_lines(lines))
+ counters = self._counters_for_zone_lines(lines)
+ op_type_data.update(counters)
zone_data["operation_types"].append(op_type_data)
+ for k, v in counters.items():
+ zone_counters[k] += v
+ zone_data.update(zone_counters)
res.append(zone_data)
return res
@@ -1597,6 +1602,7 @@ def _schema_for_select_zone(self):
zone_schema["operation_types"] = self.schemas._schema_list_of(
picking_type_schema
)
+ zone_schema.update(self._schema_for_zone_line_counters)
zone_schema = {
"zones": self.schemas._schema_list_of(zone_schema),
}
From 4eac48a230293cc0edecd7e469c6e47b7fc1b1c7 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Fri, 20 Nov 2020 14:25:31 +0100
Subject: [PATCH 447/940] shopfloor_mobile: zone_picking add counters per zone
---
shopfloor/tests/test_zone_picking_start.py | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py
index 2019e9969b..6f20c4eb95 100644
--- a/shopfloor/tests/test_zone_picking_start.py
+++ b/shopfloor/tests/test_zone_picking_start.py
@@ -37,6 +37,10 @@ def test_data_for_zone(self):
zones_data = self.service._response_for_start()["data"]["start"]["zones"]
expected_sub1 = dict(
self.data.location(self.zone_sublocation1),
+ lines_count=1,
+ picking_count=1,
+ priority_lines_count=0,
+ priority_picking_count=0,
operation_types=[
dict(
op_type_data,
@@ -49,6 +53,10 @@ def test_data_for_zone(self):
)
expected_sub2 = dict(
self.data.location(self.zone_sublocation2),
+ lines_count=2,
+ picking_count=1,
+ priority_lines_count=0,
+ priority_picking_count=0,
operation_types=[
dict(
op_type_data,
@@ -61,6 +69,10 @@ def test_data_for_zone(self):
)
expected_sub3 = dict(
self.data.location(self.zone_sublocation3),
+ lines_count=2,
+ picking_count=2,
+ priority_lines_count=0,
+ priority_picking_count=0,
operation_types=[
dict(
op_type_data,
@@ -73,6 +85,10 @@ def test_data_for_zone(self):
)
expected_sub4 = dict(
self.data.location(self.zone_sublocation4),
+ lines_count=3,
+ picking_count=2,
+ priority_lines_count=0,
+ priority_picking_count=0,
operation_types=[
dict(
op_type_data,
@@ -85,6 +101,10 @@ def test_data_for_zone(self):
)
expected_sub5 = dict(
self.data.location(self.zone_sublocation5),
+ lines_count=2,
+ picking_count=1,
+ priority_lines_count=0,
+ priority_picking_count=0,
operation_types=[
dict(
op_type_data,
From 370a648b1676d52c47b0f3a89133eccdda7d1ab8 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 23 Nov 2020 10:33:23 +0100
Subject: [PATCH 448/940] shopfloor: add common search_move_line actions
---
shopfloor/actions/__init__.py | 1 +
shopfloor/actions/move_line_search.py | 105 ++++++++++++++++++++++++++
shopfloor/services/service.py | 26 +++++--
3 files changed, 126 insertions(+), 6 deletions(-)
create mode 100644 shopfloor/actions/move_line_search.py
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index 464f279228..788ec5e8a9 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -26,3 +26,4 @@
from . import search
from . import inventory
from . import savepoint
+from . import move_line_search
diff --git a/shopfloor/actions/move_line_search.py b/shopfloor/actions/move_line_search.py
new file mode 100644
index 0000000000..21d11c23bc
--- /dev/null
+++ b/shopfloor/actions/move_line_search.py
@@ -0,0 +1,105 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo.addons.component.core import Component
+
+
+class MoveLineSearch(Component):
+ """Provide methods to search move line records.
+
+ The methods should be used in Service Components, so a search will always
+ have the same result in all scenarios.
+ """
+
+ _name = "shopfloor.search.move.line"
+ _inherit = "shopfloor.process.action"
+ _usage = "search_move_line"
+
+ @property
+ def picking_types(self):
+ return getattr(
+ self.work, "picking_types", self.env["stock.picking.type"].browse()
+ )
+
+ def _search_move_lines_by_location_domain(
+ self,
+ locations,
+ picking_type=None,
+ package=None,
+ product=None,
+ lot=None,
+ match_user=False,
+ ):
+ domain = [
+ ("location_id", "child_of", locations.ids),
+ ("qty_done", "=", 0),
+ ("state", "in", ("assigned", "partially_available")),
+ ]
+ if picking_type:
+ # auto_join in place for this field
+ domain += [("picking_id.picking_type_id", "=", picking_type.id)]
+ elif self.picking_types:
+ domain += [("picking_id.picking_type_id", "in", self.picking_types.ids)]
+ if package:
+ domain += [("package_id", "=", package.id)]
+ if product:
+ domain += [("product_id", "=", product.id)]
+ if lot:
+ domain += [("lot_id", "=", lot.id)]
+ if match_user:
+ domain += [
+ "|",
+ ("shopfloor_user_id", "=", False),
+ ("shopfloor_user_id", "=", self.env.uid),
+ ]
+ return domain
+
+ def search_move_lines_by_location(
+ self,
+ locations,
+ picking_type=None,
+ package=None,
+ product=None,
+ lot=None,
+ order="priority",
+ match_user=False,
+ sort_keys_func=None,
+ ):
+ """Find lines that potentially need work in given locations."""
+ move_lines = self.env["stock.move.line"].search(
+ self._search_move_lines_by_location_domain(
+ locations, picking_type, package, product, lot, match_user=match_user
+ )
+ )
+ sort_keys_func = sort_keys_func or self._sort_key_move_lines(order)
+ move_lines = move_lines.sorted(sort_keys_func)
+ return move_lines
+
+ @staticmethod
+ def _sort_key_move_lines(order):
+ """Return a sorting function to order lines."""
+
+ if order == "priority":
+ # make prority negative to keep sorting ascending
+ return lambda line: (
+ -int(line.move_id.priority or "0"),
+ line.move_id.date_expected,
+ )
+ elif order == "location":
+ return lambda line: (
+ line.location_id.shopfloor_picking_sequence or "",
+ line.location_id.name,
+ line.move_id.date_expected,
+ )
+ return lambda line: line
+
+ def counters_for_lines(self, lines, priority_selection=("2", "3")):
+ # Not using mapped/filtered to support simple lists and generators
+ priority_lines = [
+ x for x in lines if x.picking_id.priority in priority_selection
+ ]
+ return {
+ "lines_count": len(lines),
+ "picking_count": len({x.picking_id.id for x in lines}),
+ "priority_lines_count": len(priority_lines),
+ "priority_picking_count": len({x.picking_id.id for x in priority_lines}),
+ }
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 603c55d90c..d228a55822 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -320,17 +320,18 @@ def _get_openapi_default_parameters(self):
def actions_collection(self):
return _PseudoCollection(self._actions_collection_name, self.env)
- def actions_for(self, usage):
+ def actions_for(self, usage, propagate_kwargs=None):
"""Return an Action Component for a usage
Action Components are the components supporting the business logic of
the processes, so we can limit the code in Services to the minimum and
share methods.
"""
+ propagate_kwargs = self.work._propagate_kwargs[:] + (propagate_kwargs or [])
# propagate custom arguments (such as menu ID/profile ID)
kwargs = {
attr_name: getattr(self.work, attr_name)
- for attr_name in self.work._propagate_kwargs
+ for attr_name in propagate_kwargs
if attr_name not in ("collection", "components_registry")
}
work = WorkContext(collection=self.actions_collection, **kwargs)
@@ -355,6 +356,11 @@ def data_detail(self):
def msg_store(self):
return self.actions_for("message")
+ @property
+ def search_move_line(self):
+ # TODO: propagating `picking_types` should probably be default
+ return self.actions_for("search_move_line", propagate_kwargs=["picking_types"])
+
# TODO: maybe to be proposed to base_rest
# TODO: add tests
def _validate_headers_update_work_context(self, request, method_name):
@@ -442,8 +448,12 @@ class BaseShopfloorProcess(AbstractComponent):
_requires_header_menu = True
_requires_header_profile = True
- @property
- def picking_types(self):
+ def __init__(self, work_context):
+ super().__init__(work_context)
+ if not hasattr(self.work, "picking_types"):
+ self.work.picking_types = self._get_process_picking_types()
+
+ def _get_process_picking_types(self):
"""Return picking types for the menu and profile"""
# TODO make this a lazy property or computed field avoid running the
# filter every time?
@@ -451,13 +461,17 @@ def picking_types(self):
lambda pt: not pt.warehouse_id
or pt.warehouse_id == self.work.profile.warehouse_id
)
- if not picking_types:
+ return picking_types
+
+ @property
+ def picking_types(self):
+ if not self.work.picking_types:
raise exceptions.UserError(
_("No operation types configured on menu {} for warehouse {}.").format(
self.work.menu.name, self.work.profile.warehouse_id.display_name
)
)
- return picking_types
+ return self.work.picking_types
def _check_picking_status(self, pickings):
"""Check if given pickings can be processed.
From deb5e460c1ae44f244956e189dbf0fa886b95d8f Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 23 Nov 2020 10:34:06 +0100
Subject: [PATCH 449/940] shopfloor: zone_picking use common search_move_line
actions
---
shopfloor/services/zone_picking.py | 81 ++++--------------------------
1 file changed, 11 insertions(+), 70 deletions(-)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 979232c8b3..010fd3c49b 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -202,20 +202,12 @@ def _data_for_select_picking_type(self, zone_location, picking_types):
return data
def _counters_for_zone_lines(self, zone_lines):
- # Not using mapped/filtered to support simple lists and generators
- priority_lines = [x for x in zone_lines if x.picking_id.priority in ("2", "3")]
- return {
- "lines_count": len(zone_lines),
- "picking_count": len({x.picking_id.id for x in zone_lines}),
- "priority_lines_count": len(priority_lines),
- "priority_picking_count": len({x.picking_id.id for x in priority_lines}),
- }
+ return self.search_move_line.counters_for_lines(zone_lines)
def _picking_type_zone_lines(self, zone_location, picking_type):
- domain = self._find_location_move_lines_domain(
+ return self.search_move_line.search_move_lines_by_location(
zone_location, picking_type=picking_type
)
- return self.env["stock.move.line"].search(domain)
def _data_for_move_line(self, zone_location, picking_type, move_line):
return {
@@ -250,9 +242,7 @@ def _data_for_location(self, zone_location, picking_type, location):
}
def _zone_lines(self, zones):
- return self.env["stock.move.line"].search(
- self._find_location_move_lines_domain(zones)
- )
+ return self._find_location_move_lines(zones)
def _data_for_select_zone(self, zones):
"""Retrieve detailed info for each zone.
@@ -286,39 +276,6 @@ def _data_for_select_zone(self, zones):
res.append(zone_data)
return res
- def _find_location_move_lines_domain(
- self,
- locations,
- picking_type=None,
- package=None,
- product=None,
- lot=None,
- match_user=False,
- ):
- domain = [
- ("location_id", "child_of", locations.ids),
- ("qty_done", "=", 0),
- ("state", "in", ("assigned", "partially_available")),
- ]
- if picking_type:
- # auto_join in place for this field
- domain += [("picking_id.picking_type_id", "=", picking_type.id)]
- else:
- domain += [("picking_id.picking_type_id", "in", self.picking_types.ids)]
- if package:
- domain += [("package_id", "=", package.id)]
- if product:
- domain += [("product_id", "=", product.id)]
- if lot:
- domain += [("lot_id", "=", lot.id)]
- if match_user:
- domain += [
- "|",
- ("shopfloor_user_id", "=", False),
- ("shopfloor_user_id", "=", self.env.uid),
- ]
- return domain
-
def _find_location_move_lines(
self,
locations,
@@ -330,31 +287,15 @@ def _find_location_move_lines(
match_user=False,
):
"""Find lines that potentially need work in given locations."""
- move_lines = self.env["stock.move.line"].search(
- self._find_location_move_lines_domain(
- locations, picking_type, package, product, lot, match_user=match_user
- )
+ return self.search_move_line.search_move_lines_by_location(
+ locations,
+ picking_type=picking_type,
+ package=package,
+ product=product,
+ lot=lot,
+ order=order,
+ match_user=match_user,
)
- sort_keys_func = self._sort_key_move_lines(order)
- move_lines = move_lines.sorted(sort_keys_func)
- return move_lines
-
- @staticmethod
- def _sort_key_move_lines(order):
- """Return a sorting function to order lines."""
-
- if order == "priority":
- # make prority negative to keep sorting ascending
- return lambda line: (
- -int(line.move_id.priority or "0"),
- line.move_id.date_expected,
- )
- elif order == "location":
- return lambda line: (
- line.location_id.shopfloor_picking_sequence or "",
- line.location_id.name,
- line.move_id.date_expected,
- )
def _find_buffer_move_lines_domain(
self, zone_location, picking_type, dest_package=None
From 2a77041b35a00ef14f57b014ddea9bb92ebedfce Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 23 Nov 2020 13:08:22 +0100
Subject: [PATCH 450/940] shopfloor: service.action_for allow custom work ctx
args
---
shopfloor/services/service.py | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index d228a55822..e2ca971ca3 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -320,7 +320,7 @@ def _get_openapi_default_parameters(self):
def actions_collection(self):
return _PseudoCollection(self._actions_collection_name, self.env)
- def actions_for(self, usage, propagate_kwargs=None):
+ def actions_for(self, usage, propagate_kwargs=None, **kw):
"""Return an Action Component for a usage
Action Components are the components supporting the business logic of
@@ -333,7 +333,9 @@ def actions_for(self, usage, propagate_kwargs=None):
attr_name: getattr(self.work, attr_name)
for attr_name in propagate_kwargs
if attr_name not in ("collection", "components_registry")
+ and hasattr(self.work, attr_name)
}
+ kwargs.update(kw)
work = WorkContext(collection=self.actions_collection, **kwargs)
return work.component(usage=usage)
@@ -448,11 +450,6 @@ class BaseShopfloorProcess(AbstractComponent):
_requires_header_menu = True
_requires_header_profile = True
- def __init__(self, work_context):
- super().__init__(work_context)
- if not hasattr(self.work, "picking_types"):
- self.work.picking_types = self._get_process_picking_types()
-
def _get_process_picking_types(self):
"""Return picking types for the menu and profile"""
# TODO make this a lazy property or computed field avoid running the
@@ -465,6 +462,8 @@ def _get_process_picking_types(self):
@property
def picking_types(self):
+ if not hasattr(self.work, "picking_types"):
+ self.work.picking_types = self._get_process_picking_types()
if not self.work.picking_types:
raise exceptions.UserError(
_("No operation types configured on menu {} for warehouse {}.").format(
From 4ddbf4c7aa9d06db331ab56f9a7b2b8e8249c252 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 23 Nov 2020 13:09:11 +0100
Subject: [PATCH 451/940] shopfloor: make move lines counters schema common
---
shopfloor/services/schema.py | 8 ++++++++
shopfloor/services/zone_picking.py | 7 +------
2 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py
index bb9071e9d7..c86e11360c 100644
--- a/shopfloor/services/schema.py
+++ b/shopfloor/services/schema.py
@@ -186,3 +186,11 @@ def picking_type(self):
"id": {"required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
}
+
+ def move_lines_counters(self):
+ return {
+ "lines_count": {"type": "float", "required": True},
+ "picking_count": {"type": "float", "required": True},
+ "priority_lines_count": {"type": "float", "required": True},
+ "priority_picking_count": {"type": "float", "required": True},
+ }
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 010fd3c49b..2ef04204a6 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -1551,12 +1551,7 @@ def _schema_for_select_zone(self):
@property
def _schema_for_zone_line_counters(self):
- return {
- "lines_count": {"type": "float", "required": True},
- "picking_count": {"type": "float", "required": True},
- "priority_lines_count": {"type": "float", "required": True},
- "priority_picking_count": {"type": "float", "required": True},
- }
+ return self.schemas.move_lines_counters()
@property
def _schema_for_select_picking_type(self):
From 2e65882066bbfb99481e5242e53e4a2bca468cdf Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 23 Nov 2020 16:00:11 +0100
Subject: [PATCH 452/940] shopfloor: add menu item counters
Provide counters on work to be done per each menu item.
---
shopfloor/services/menu.py | 51 ++++--
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_menu.py | 34 +---
shopfloor/tests/test_menu_base.py | 253 ++++++++++++++++++++++++++
shopfloor/tests/test_menu_counters.py | 24 +++
5 files changed, 311 insertions(+), 52 deletions(-)
create mode 100644 shopfloor/tests/test_menu_base.py
create mode 100644 shopfloor/tests/test_menu_counters.py
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index 3fce4f2182..cb82e53517 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -59,16 +59,31 @@ def search(self, name_fragment=None):
)
def _convert_one_record(self, record):
- # TODO: use `jsonify`
- return {
- "id": record.id,
- "name": record.name,
- "scenario": record.scenario,
- "picking_types": [
- {"id": picking_type.id, "name": picking_type.name}
- for picking_type in record.picking_type_ids
- ],
- }
+ values = record.jsonify(self._one_record_parser, one=True)
+ counters = self._get_move_line_counters(record)
+ values.update(counters)
+ return values
+
+ def _get_move_line_counters(self, record):
+ """Lookup for all lines per menu item and compute counters.
+ """
+ # TODO: maybe to be improved w/ raw SQL as this run for each menu item
+ # and it's called every time the menu is opened/gets refreshed
+ move_line_search = self.actions_for(
+ "search_move_line", picking_types=record.picking_type_ids
+ )
+ locations = record.picking_type_ids.mapped("default_location_src_id")
+ lines_per_menu = move_line_search.search_move_lines_by_location(locations)
+ return move_line_search.counters_for_lines(lines_per_menu)
+
+ @property
+ def _one_record_parser(self):
+ return [
+ "id",
+ "name",
+ "scenario",
+ ("picking_type_ids:picking_types", ["id", "name"]),
+ ]
class ShopfloorMenuValidator(Component):
@@ -92,28 +107,24 @@ class ShopfloorMenuValidatorResponse(Component):
_usage = "menu.validator.response"
def return_search(self):
+ record_schema = self._record_schema
return self._response_schema(
{
"size": {"coerce": to_int, "required": True, "type": "integer"},
- "records": {
- "type": "list",
- "required": True,
- "schema": {"type": "dict", "schema": self._record_schema},
- },
+ "records": self.schemas._schema_list_of(record_schema),
}
)
@property
def _record_schema(self):
- return {
+ schema = {
"id": {"coerce": to_int, "required": True, "type": "integer"},
"name": {"type": "string", "nullable": False, "required": True},
"scenario": {"type": "string", "nullable": False, "required": True},
- "picking_types": {
- "type": "list",
- "schema": {"type": "dict", "schema": self._picking_type_schema},
- },
+ "picking_types": self.schemas._schema_list_of(self._picking_type_schema),
}
+ schema.update(self.schemas.move_lines_counters())
+ return schema
@property
def _picking_type_schema(self):
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 415f01e90c..c15f412658 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -1,6 +1,7 @@
from . import test_app
from . import test_user
from . import test_menu
+from . import test_menu_counters
from . import test_openapi
from . import test_profile
from . import test_actions_change_package_lot
diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py
index 5ec40e40d7..c27b937bde 100644
--- a/shopfloor/tests/test_menu.py
+++ b/shopfloor/tests/test_menu.py
@@ -1,40 +1,10 @@
# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
-from .common import CommonCase
+from .test_menu_base import CommonMenuCase
-class MenuCase(CommonCase):
- @classmethod
- def setUpClassVars(cls, *args, **kwargs):
- super().setUpClassVars(*args, **kwargs)
- cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
-
- def setUp(self):
- super().setUp()
- with self.work_on_services(profile=self.profile) as work:
- self.service = work.component(usage="menu")
-
- def _assert_menu_response(self, response, menus):
- self.assert_response(
- response,
- data={
- "size": len(menus),
- "records": [
- {
- "id": menu.id,
- "name": menu.name,
- "scenario": menu.scenario,
- "picking_types": [
- {"id": picking_type.id, "name": picking_type.name}
- for picking_type in menu.picking_type_ids
- ],
- }
- for menu in menus
- ],
- },
- )
-
+class MenuCase(CommonMenuCase):
def test_menu_search(self):
"""Request /menu/search"""
# Simulate the client searching menus
diff --git a/shopfloor/tests/test_menu_base.py b/shopfloor/tests/test_menu_base.py
new file mode 100644
index 0000000000..a85d407677
--- /dev/null
+++ b/shopfloor/tests/test_menu_base.py
@@ -0,0 +1,253 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from .common import CommonCase
+
+
+class CommonMenuCase(CommonCase):
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo")
+
+ def setUp(self):
+ super().setUp()
+ with self.work_on_services(profile=self.profile) as work:
+ self.service = work.component(usage="menu")
+
+ def _assert_menu_response(self, response, menus, expected_counters=None):
+ self.assert_response(
+ response,
+ data={
+ "size": len(menus),
+ "records": [
+ self._data_for_menu_item(menu, expected_counters=expected_counters)
+ for menu in menus
+ ],
+ },
+ )
+
+ def _data_for_menu_item(self, menu, expected_counters=None):
+ expected_counters = expected_counters or {}
+ data = {
+ "id": menu.id,
+ "name": menu.name,
+ "scenario": menu.scenario,
+ "picking_types": [
+ {"id": picking_type.id, "name": picking_type.name}
+ for picking_type in menu.picking_type_ids
+ ],
+ }
+ counters = expected_counters.get(
+ menu.id,
+ {
+ "lines_count": 0,
+ "picking_count": 0,
+ "priority_lines_count": 0,
+ "priority_picking_count": 0,
+ },
+ )
+ data.update(counters)
+ return data
+
+
+class MenuCountersCommonCase(CommonMenuCase):
+ @classmethod
+ def setUpClassVars(cls, *args, **kwargs):
+ super().setUpClassVars(*args, **kwargs)
+ cls.menu1 = cls.env.ref("shopfloor.shopfloor_menu_zone_picking")
+ cls.menu2 = cls.env.ref("shopfloor.shopfloor_menu_cluster_picking")
+ cls.menu1_picking_type = cls.menu1.picking_type_ids[0]
+ cls.menu2_picking_type = cls.menu2.picking_type_ids[0]
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.packing_location.sudo().active = True
+ # We want to limit the tests to a dedicated location in Stock/ to not
+ # be bothered with pickings brought by demo data
+ cls.zone_location1 = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone location 1",
+ "location_id": cls.stock_location.id,
+ "barcode": "ZONE_LOCATION_1",
+ }
+ )
+ )
+ cls.zone_location2 = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone location 2",
+ "location_id": cls.stock_location.id,
+ "barcode": "ZONE_LOCATION_2",
+ }
+ )
+ )
+ # Set default location for our picking types
+ cls.menu1_picking_type.sudo().default_location_src_id = cls.zone_location1
+ cls.menu2_picking_type.sudo().default_location_src_id = cls.zone_location2
+ cls.zone_sublocation1 = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone sub-location 1",
+ "location_id": cls.zone_location1.id,
+ "barcode": "ZONE_SUBLOCATION_1",
+ }
+ )
+ )
+ cls.zone_sublocation2 = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone sub-location 2",
+ "location_id": cls.zone_location2.id,
+ "barcode": "ZONE_SUBLOCATION_2",
+ }
+ )
+ )
+ cls.zone_sublocation3 = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone sub-location 3",
+ "location_id": cls.zone_location2.id,
+ "barcode": "ZONE_SUBLOCATION_3",
+ }
+ )
+ )
+ cls.zone_sublocation4 = (
+ cls.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "Zone sub-location 4",
+ "location_id": cls.zone_location2.id,
+ "barcode": "ZONE_SUBLOCATION_4",
+ }
+ )
+ )
+ cls.product_e = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product E",
+ "type": "product",
+ "default_code": "E",
+ "barcode": "E",
+ "weight": 3,
+ }
+ )
+ )
+ cls.product_f = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product F",
+ "type": "product",
+ "default_code": "F",
+ "barcode": "F",
+ "weight": 3,
+ }
+ )
+ )
+ cls.product_g = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product G",
+ "type": "product",
+ "default_code": "G",
+ "barcode": "G",
+ "weight": 3,
+ }
+ )
+ )
+ cls.product_h = (
+ cls.env["product.product"]
+ .sudo()
+ .create(
+ {
+ "name": "Product H",
+ "type": "product",
+ "default_code": "H",
+ "barcode": "H",
+ "weight": 3,
+ }
+ )
+ )
+ products = (
+ cls.product_a
+ + cls.product_b
+ + cls.product_c
+ + cls.product_d
+ + cls.product_e
+ + cls.product_f
+ + cls.product_g
+ + cls.product_h
+ )
+ for product in products:
+ cls.env["stock.putaway.rule"].sudo().create(
+ {
+ "product_id": product.id,
+ "location_in_id": cls.stock_location.id,
+ "location_out_id": cls.shelf1.id,
+ }
+ )
+
+ cls.picking1 = picking1 = cls._create_picking(
+ picking_type=cls.menu1_picking_type, lines=[(cls.product_a, 10)]
+ )
+ picking1.priority = "2"
+ cls._fill_stock_for_moves(
+ picking1.move_lines, in_package=True, location=cls.zone_sublocation1
+ )
+
+ cls.picking2 = picking2 = cls._create_picking(
+ picking_type=cls.menu1_picking_type,
+ lines=[(cls.product_b, 10), (cls.product_c, 10)],
+ )
+ picking2.priority = "3"
+ cls._fill_stock_for_moves(
+ picking2.move_lines, in_lot=True, location=cls.zone_sublocation2
+ )
+
+ cls.picking3 = picking3 = cls._create_picking(
+ picking_type=cls.menu1_picking_type, lines=[(cls.product_d, 10)]
+ )
+ picking3.priority = "2"
+ cls._fill_stock_for_moves(picking3.move_lines, location=cls.zone_sublocation1)
+
+ cls.picking4 = picking4 = cls._create_picking(
+ picking_type=cls.menu2_picking_type, lines=[(cls.product_e, 10)]
+ )
+ cls._update_qty_in_location(cls.zone_sublocation3, cls.product_e, 6)
+ cls._update_qty_in_location(cls.zone_sublocation4, cls.product_e, 4)
+
+ cls.picking5 = picking5 = cls._create_picking(
+ picking_type=cls.menu2_picking_type,
+ lines=[(cls.product_b, 10), (cls.product_f, 10)],
+ )
+ cls._fill_stock_for_moves(
+ picking5.move_lines, in_package=True, location=cls.zone_sublocation2
+ )
+ cls.picking6 = picking6 = cls._create_picking(
+ picking_type=cls.menu2_picking_type,
+ lines=[(cls.product_g, 6), (cls.product_h, 6)],
+ )
+ cls._update_qty_in_location(cls.zone_sublocation2, cls.product_g, 6)
+ cls._update_qty_in_location(cls.zone_sublocation2, cls.product_h, 3)
+
+ cls.pickings = picking1 | picking2 | picking3 | picking4 | picking5 | picking6
+ cls.pickings.action_assign()
diff --git a/shopfloor/tests/test_menu_counters.py b/shopfloor/tests/test_menu_counters.py
new file mode 100644
index 0000000000..8cec852e08
--- /dev/null
+++ b/shopfloor/tests/test_menu_counters.py
@@ -0,0 +1,24 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from .test_menu_base import MenuCountersCommonCase
+
+
+class TestMenuCountersCommonCase(MenuCountersCommonCase):
+ def test_menu_search(self):
+ expected_counters = {
+ self.menu1.id: {
+ "lines_count": 2,
+ "picking_count": 2,
+ "priority_lines_count": 2,
+ "priority_picking_count": 2,
+ },
+ self.menu2.id: {
+ "lines_count": 6,
+ "picking_count": 3,
+ "priority_lines_count": 0,
+ "priority_picking_count": 0,
+ },
+ }
+ response = self.service.dispatch("search")
+ menus = self.env["shopfloor.menu"].search([])
+ self._assert_menu_response(response, menus, expected_counters=expected_counters)
From 09f8c9e1a86271d70789f2486e0989ae3308ee5d Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 23 Nov 2020 16:01:34 +0100
Subject: [PATCH 453/940] shopfloor: action search always look for 1 record
---
shopfloor/actions/search.py | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py
index 922df08040..4592653ab8 100644
--- a/shopfloor/actions/search.py
+++ b/shopfloor/actions/search.py
@@ -17,27 +17,31 @@ class SearchAction(Component):
def location_from_scan(self, barcode):
if not barcode:
return self.env["stock.location"].browse()
- return self.env["stock.location"].search([("barcode", "=", barcode)])
+ return self.env["stock.location"].search([("barcode", "=", barcode)], limit=1)
def package_from_scan(self, barcode):
- return self.env["stock.quant.package"].search([("name", "=", barcode)])
+ return self.env["stock.quant.package"].search([("name", "=", barcode)], limit=1)
def picking_from_scan(self, barcode):
- return self.env["stock.picking"].search([("name", "=", barcode)])
+ return self.env["stock.picking"].search([("name", "=", barcode)], limit=1)
def product_from_scan(self, barcode):
- product = self.env["product.product"].search([("barcode", "=", barcode)])
+ product = self.env["product.product"].search(
+ [("barcode", "=", barcode)], limit=1
+ )
if not product:
packaging = self.env["product.packaging"].search(
- [("product_id", "!=", False), ("barcode", "=", barcode)]
+ [("product_id", "!=", False), ("barcode", "=", barcode)], limit=1
)
product = packaging.product_id
return product
def lot_from_scan(self, barcode):
- return self.env["stock.production.lot"].search([("name", "=", barcode)])
+ return self.env["stock.production.lot"].search(
+ [("name", "=", barcode)], limit=1
+ )
def generic_packaging_from_scan(self, barcode):
return self.env["product.packaging"].search(
- [("barcode", "=", barcode), ("product_id", "=", False)]
+ [("barcode", "=", barcode), ("product_id", "=", False)], limit=1
)
From 2bc77b8759275e90ae7f23468b7fefefdc333aa6 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 26 Nov 2020 15:04:22 +0100
Subject: [PATCH 454/940] shopfloor: fix db logging tests
---
shopfloor/models/shopfloor_log.py | 2 +-
shopfloor/tests/__init__.py | 1 +
shopfloor/tests/test_db_logging.py | 18 +++++++++++++-----
3 files changed, 15 insertions(+), 6 deletions(-)
diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py
index 62bb26519b..110f04cf08 100644
--- a/shopfloor/models/shopfloor_log.py
+++ b/shopfloor/models/shopfloor_log.py
@@ -85,7 +85,7 @@ def _get_exception_severity_mapping(self):
if not exc_name or not severity:
raise ValueError
except ValueError:
- _logger.exception(
+ _logger.info(
"Could not convert System Parameter"
" 'shopfloor.log.severity.exception.mapping' to mapping."
" The following rule will be ignored: %s",
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index c15f412658..3146498a5a 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -63,3 +63,4 @@
from . import test_scan_anything
from . import test_stock_split
from . import test_picking_form
+from . import test_db_logging
diff --git a/shopfloor/tests/test_db_logging.py b/shopfloor/tests/test_db_logging.py
index 6795327292..7a982b9169 100644
--- a/shopfloor/tests/test_db_logging.py
+++ b/shopfloor/tests/test_db_logging.py
@@ -5,6 +5,7 @@
import json
from odoo import exceptions
+from odoo.tools import mute_logger
from odoo.addons.website.tools import MockRequest
@@ -101,17 +102,23 @@ def test_log_entry_values_success(self):
expected = {
"request_url": httprequest["url"],
"request_method": httprequest["method"],
- "params": json.dumps(dict(params, _id=_id)),
- "headers": json.dumps(
- {"Cookie": "", "Api-Key": "", "KEEP-ME": "FOO"}
- ),
"state": "success",
- "result": json.dumps({"data": "worked!"}),
"error": False,
"exception_name": False,
"severity": False,
}
self.assertRecordValues(entry, [expected])
+ expected_json = {
+ "result": {"data": "worked!"},
+ "params": dict(params, _id=_id),
+ "headers": {
+ "Cookie": "",
+ "Api-Key": "",
+ "KEEP-ME": "FOO",
+ },
+ }
+ for k, v in expected_json.items():
+ self.assertEqual(json.loads(entry[k]), v)
def test_log_entry_values_failed(self):
_id = "whatever-id"
@@ -203,6 +210,7 @@ def test_log_entry_severity_mapping_param(self):
self.assertEqual(mapping["odoo.exceptions.UserError"], "severe")
self._test_log_entry_values_failed_with_exception_default("warning")
+ @mute_logger("odoo.addons.shopfloor.models.shopfloor_log")
def test_log_entry_severity_mapping_param_bad_values(self):
# bad values are discarded
value = """
From 61b154e820c3f2b06f1f0b6bbb04afe4fc3f57dd Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 26 Nov 2020 15:04:47 +0100
Subject: [PATCH 455/940] shopfloor: fix user service tests
---
shopfloor/tests/test_user.py | 34 ++++------------------------------
1 file changed, 4 insertions(+), 30 deletions(-)
diff --git a/shopfloor/tests/test_user.py b/shopfloor/tests/test_user.py
index 066a3f2b3b..e1175ae8bb 100644
--- a/shopfloor/tests/test_user.py
+++ b/shopfloor/tests/test_user.py
@@ -1,9 +1,9 @@
# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
-from .common import CommonCase
+from .test_menu_base import CommonMenuCase
-class UserCase(CommonCase):
+class UserCase(CommonMenuCase):
@classmethod
def setUpClassVars(cls, *args, **kwargs):
super().setUpClassVars(*args, **kwargs)
@@ -22,20 +22,7 @@ def test_menu_no_profile(self):
menus = self.env["shopfloor.menu"].search([])
self.assert_response(
response,
- data={
- "menus": [
- {
- "id": menu.id,
- "name": menu.name,
- "scenario": menu.scenario,
- "picking_types": [
- {"id": picking_type.id, "name": picking_type.name}
- for picking_type in menu.picking_type_ids
- ],
- }
- for menu in menus
- ]
- },
+ data={"menus": [self._data_for_menu_item(menu) for menu in menus]},
)
def test_menu_by_profile(self):
@@ -48,18 +35,5 @@ def test_menu_by_profile(self):
response = self.service.dispatch("menu")
self.assert_response(
- response,
- data={
- "menus": [
- {
- "id": menu.id,
- "name": menu.name,
- "scenario": menu.scenario,
- "picking_types": [
- {"id": picking_type.id, "name": picking_type.name}
- for picking_type in menu.picking_type_ids
- ],
- }
- ]
- },
+ response, data={"menus": [self._data_for_menu_item(menu)]},
)
From e14e2e57f191f2a37383dcc131859024334797e0 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 26 Nov 2020 15:38:05 +0100
Subject: [PATCH 456/940] shopfloor: fix picking types propagation for scenario
---
shopfloor/services/service.py | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index e2ca971ca3..dcfe75f6dc 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -472,6 +472,14 @@ def picking_types(self):
)
return self.work.picking_types
+ @property
+ def search_move_line(self):
+ # TODO: picking types should be set somehow straight in the work context
+ # by `_validate_headers_update_work_context` in this way
+ # we can remove this override and the need to call `_get_process_picking_types`
+ # every time.
+ return self.actions_for("search_move_line", picking_types=self.picking_types)
+
def _check_picking_status(self, pickings):
"""Check if given pickings can be processed.
From a7df6f4c0b51bc3665bee00ffb50c59ed5214bd5 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 24 Nov 2020 09:41:35 +0100
Subject: [PATCH 457/940] shopfloor bump 13.0.1.4.0
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 4f6dfa30d2..fe42dc3e39 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.3.7",
+ "version": "13.0.1.4.0",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From eaeadbf5cd91c61051b0cc0723c1aca832fd9cc3 Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Tue, 1 Dec 2020 16:16:08 +0000
Subject: [PATCH 458/940] shopfloor 13.0.1.5.0
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index fe42dc3e39..4ed966e3a5 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.4.0",
+ "version": "13.0.1.5.0",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From 3a2d463eb64737ecf034714e7683986ab45d0ba3 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 1 Dec 2020 10:56:13 +0100
Subject: [PATCH 459/940] shopfloor: fix change package w/ same record
Prevent errors like
odoo.exceptions.MissingError:
Record does not exist or has been deleted.
(Record: stock.move.line(3557,), User: 605)
---
shopfloor/actions/change_package_lot.py | 7 +++++++
shopfloor/actions/message.py | 6 ++++++
.../tests/test_actions_change_package_lot.py | 20 +++++++++++++++++++
3 files changed, 33 insertions(+)
diff --git a/shopfloor/actions/change_package_lot.py b/shopfloor/actions/change_package_lot.py
index c21e8e92e8..82876f1d04 100644
--- a/shopfloor/actions/change_package_lot.py
+++ b/shopfloor/actions/change_package_lot.py
@@ -175,6 +175,13 @@ def _package_content_replacement_allowed(self, package, move_line):
return move_line.product_id in package.quant_ids.product_id
def change_package(self, move_line, package, response_ok_func, response_error_func):
+ # Prevent change if package is already set and it's the same
+ if move_line.package_id == package:
+ return response_error_func(
+ move_line,
+ message=self.msg_store.package_change_error_same_package(package),
+ )
+
# prevent to replace a package by a package that would not satisfy the
# move (different product)
content_replacement_allowed = self._package_content_replacement_allowed(
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 90cdf2f174..a5e6c924af 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -239,6 +239,12 @@ def package_different_content(self, package):
"body": _("Package {} has a different content.").format(package.name),
}
+ def package_change_error_same_package(self, package):
+ return {
+ "message_type": "error",
+ "body": _("Same package {} is already assigned.").format(package.name),
+ }
+
def x_units_put_in_package(self, qty, product, package):
return {
"message_type": "success",
diff --git a/shopfloor/tests/test_actions_change_package_lot.py b/shopfloor/tests/test_actions_change_package_lot.py
index 967729f044..03678c0b56 100644
--- a/shopfloor/tests/test_actions_change_package_lot.py
+++ b/shopfloor/tests/test_actions_change_package_lot.py
@@ -1167,3 +1167,23 @@ def test_package_2_lines_1_move(self):
self.assertEqual(package1.quant_ids.reserved_quantity, 4)
self.assertEqual(package2.quant_ids.reserved_quantity, 0)
self.assertEqual(package3.quant_ids.reserved_quantity, 6)
+
+ def test_change_pack_same(self):
+ initial_package = self._create_package_in_location(
+ self.shelf1, [self.PackageContent(self.product_a, 100, lot=None)]
+ )
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ picking.action_assign()
+ line = picking.move_line_ids
+ self.assertEqual(line.package_id, initial_package)
+ self.change_package_lot.change_package(
+ line,
+ initial_package,
+ # success callback
+ self.unreachable_func,
+ # failure callback
+ lambda move_line, message=None: self.assertEqual(
+ message,
+ self.msg_store.package_change_error_same_package(initial_package),
+ ),
+ )
From 1dded5dfcd004b6d32875b3222429c261808e248 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 1 Dec 2020 12:04:05 +0100
Subject: [PATCH 460/940] shopfloor: cluster picking split scan tests
---
shopfloor/tests/__init__.py | 4 +-
.../tests/test_cluster_picking_is_zero.py | 100 +++
shopfloor/tests/test_cluster_picking_scan.py | 704 ------------------
.../test_cluster_picking_scan_destination.py | 336 +++++++++
.../tests/test_cluster_picking_scan_line.py | 273 +++++++
5 files changed, 712 insertions(+), 705 deletions(-)
create mode 100644 shopfloor/tests/test_cluster_picking_is_zero.py
delete mode 100644 shopfloor/tests/test_cluster_picking_scan.py
create mode 100644 shopfloor/tests/test_cluster_picking_scan_destination.py
create mode 100644 shopfloor/tests/test_cluster_picking_scan_line.py
diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py
index 3146498a5a..6013d93885 100644
--- a/shopfloor/tests/__init__.py
+++ b/shopfloor/tests/__init__.py
@@ -12,7 +12,9 @@
from . import test_cluster_picking_base
from . import test_cluster_picking_batch
from . import test_cluster_picking_select
-from . import test_cluster_picking_scan
+from . import test_cluster_picking_scan_line
+from . import test_cluster_picking_scan_destination
+from . import test_cluster_picking_is_zero
from . import test_cluster_picking_skip
from . import test_cluster_picking_stock_issue
from . import test_cluster_picking_change_pack_lot
diff --git a/shopfloor/tests/test_cluster_picking_is_zero.py b/shopfloor/tests/test_cluster_picking_is_zero.py
new file mode 100644
index 0000000000..470cf8cba0
--- /dev/null
+++ b/shopfloor/tests/test_cluster_picking_is_zero.py
@@ -0,0 +1,100 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from .test_cluster_picking_base import ClusterPickingCommonCase
+
+
+class ClusterPickingIsZeroCase(ClusterPickingCommonCase):
+ """Tests covering the /is_zero endpoint
+
+ After a line has been scanned, if the location is empty, the
+ client application is redirected to the "zero_check" state,
+ where the user has to confirm or not that the location is empty.
+ When the location is empty, there is nothing to do, but when it
+ in fact not empty, a draft inventory must be created for the
+ product so someone can verify.
+ """
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.batch = cls._create_picking_batch(
+ [
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10),
+ cls.BatchProduct(product=cls.product_b, quantity=10),
+ ]
+ ]
+ )
+ cls.picking = cls.batch.picking_ids
+ cls._simulate_batch_selected(cls.batch)
+
+ cls.line = cls.picking.move_line_ids[0]
+ cls.next_line = cls.picking.move_line_ids[1]
+ cls.bin1 = cls.env["stock.quant.package"].create({})
+ cls._update_qty_in_location(
+ cls.line.location_id, cls.line.product_id, cls.line.product_uom_qty
+ )
+ # we already scan and put the first line in bin1, at this point the
+ # system see the location is empty and reach "zero_check"
+ cls._set_dest_package_and_done(cls.line, cls.bin1)
+
+ def test_is_zero_is_empty(self):
+ """call /is_zero confirming it's empty"""
+ response = self.service.dispatch(
+ "is_zero",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": self.line.id,
+ "zero": True,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(self.next_line),
+ message={
+ "message_type": "success",
+ "body": "{} {} put in {}".format(
+ self.line.qty_done,
+ self.line.product_id.display_name,
+ self.bin1.name,
+ ),
+ },
+ )
+
+ def test_is_zero_is_not_empty(self):
+ """call /is_zero not confirming it's empty"""
+ response = self.service.dispatch(
+ "is_zero",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": self.line.id,
+ "zero": False,
+ },
+ )
+ inventory = self.env["stock.inventory"].search(
+ [
+ ("location_ids", "in", self.line.location_id.id),
+ ("product_ids", "in", self.line.product_id.id),
+ ("state", "=", "draft"),
+ ]
+ )
+ self.assertTrue(inventory)
+ self.assertEqual(
+ inventory.name,
+ "Zero check issue on location Stock ({})".format(self.picking.name),
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(self.next_line),
+ message={
+ "message_type": "success",
+ "body": "{} {} put in {}".format(
+ self.line.qty_done,
+ self.line.product_id.display_name,
+ self.bin1.name,
+ ),
+ },
+ )
diff --git a/shopfloor/tests/test_cluster_picking_scan.py b/shopfloor/tests/test_cluster_picking_scan.py
deleted file mode 100644
index 49326a0f2b..0000000000
--- a/shopfloor/tests/test_cluster_picking_scan.py
+++ /dev/null
@@ -1,704 +0,0 @@
-# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
-# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
-
-from .test_cluster_picking_base import (
- ClusterPickingCommonCase,
- ClusterPickingLineCommonCase,
-)
-
-
-class ClusterPickingScanLineCase(ClusterPickingLineCommonCase):
- """Tests covering the /scan_line endpoint
-
- After a batch has been selected and the user confirmed they are
- working on it.
-
- User scans something and the scan_line endpoints validates they
- scanned the proper thing to pick.
- """
-
- def _scan_line_ok(self, line, scanned):
- batch = line.picking_id.batch_id
- response = self.service.dispatch(
- "scan_line",
- params={
- "picking_batch_id": batch.id,
- "move_line_id": line.id,
- "barcode": scanned,
- },
- )
- self.assert_response(
- response, next_state="scan_destination", data=self._line_data(line)
- )
-
- def _scan_line_error(self, line, scanned, message):
- batch = line.picking_id.batch_id
- response = self.service.dispatch(
- "scan_line",
- params={
- "picking_batch_id": batch.id,
- "move_line_id": line.id,
- "barcode": scanned,
- },
- )
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(line),
- message=message,
- )
-
- def test_scan_line_pack_ok(self):
- """Scan to check if user picks the correct pack for current line"""
- self._simulate_batch_selected(self.batch, in_package=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.package_id.name)
-
- def test_scan_line_product_ok(self):
- """Scan to check if user picks the correct product for current line"""
- self._simulate_batch_selected(self.batch)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.product_id.barcode)
-
- def test_scan_line_lot_ok(self):
- """Scan to check if user picks the correct lot for current line"""
- self.product_a.tracking = "lot"
- self._simulate_batch_selected(self.batch, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.lot_id.name)
-
- def test_scan_line_serial_ok(self):
- """Scan to check if user picks the correct serial for current line"""
- self.product_a.tracking = "serial"
- self._simulate_batch_selected(self.batch, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.lot_id.name)
-
- def test_scan_line_error_product_tracked(self):
- """Scan a product tracked by lot, must scan the lot"""
- self.product_a.tracking = "lot"
- self._simulate_batch_selected(self.batch, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_error(
- line,
- line.product_id.barcode,
- {
- "message_type": "warning",
- "body": "Product tracked by lot, please scan one.",
- },
- )
-
- def test_scan_line_product_error_several_packages(self):
- """When we scan a product which is in more than one package, error"""
- self._simulate_batch_selected(self.batch, in_package=True)
- line = self.batch.picking_ids.move_line_ids
- # create a second move line for the same product in a different
- # package
- move = line.move_id.copy()
- self._fill_stock_for_moves(move, in_package=True)
- move._action_confirm(merge=False)
- move._action_assign()
-
- self._scan_line_error(
- line,
- move.product_id.barcode,
- {
- "message_type": "warning",
- "body": "This product is part of multiple"
- " packages, please scan a package.",
- },
- )
-
- def test_scan_line_product_error_in_one_package_and_unit(self):
- """When we scan a product which is in a package and as raw, error"""
- self._simulate_batch_selected(self.batch, in_package=True)
- line = self.batch.picking_ids.move_line_ids
- # create a second move line for the same product in a different
- # package
- move = line.move_id.copy()
- self._fill_stock_for_moves(move)
- move._action_confirm(merge=False)
- move._action_assign()
-
- self._scan_line_error(
- line,
- move.product_id.barcode,
- {
- "message_type": "warning",
- "body": "This product is part of multiple"
- " packages, please scan a package.",
- },
- )
-
- def test_scan_line_lot_error_several_packages(self):
- """When we scan a lot which is in more than one package, error"""
- self._simulate_batch_selected(self.batch, in_package=True, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- # create a second move line for the same product in a different
- # package
- move = line.move_id.copy()
- self._fill_stock_for_moves(move, in_lot=line.lot_id)
- move._action_confirm(merge=False)
- move._action_assign()
-
- self._scan_line_error(
- line,
- line.lot_id.name,
- {
- "message_type": "warning",
- "body": "This lot is part of multiple"
- " packages, please scan a package.",
- },
- )
-
- def test_scan_line_lot_error_in_one_package_and_unit(self):
- """When we scan a lot which is in a package and as raw, error"""
- self._simulate_batch_selected(self.batch, in_package=True, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- # create a second move line for the same product in a different
- # package
- move = line.move_id.copy()
- self._fill_stock_for_moves(move, in_lot=line.lot_id)
- move._action_confirm(merge=False)
- move._action_assign()
- self._scan_line_error(
- line,
- line.lot_id.name,
- {
- "message_type": "warning",
- "body": "This lot is part of multiple"
- " packages, please scan a package.",
- },
- )
-
- def test_scan_line_location_ok_single_package(self):
- """Scan to check if user scans a correct location for current line
-
- If there is only one single package in the location, there is no
- ambiguity so we can use it.
- """
- self._simulate_batch_selected(self.batch, in_package=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.location_id.barcode)
-
- def test_scan_line_location_ok_single_product(self):
- """Scan to check if user scans a correct location for current line
-
- If there is only one single product in the location, there is no
- ambiguity so we can use it.
- """
- self._simulate_batch_selected(self.batch)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.location_id.barcode)
-
- def test_scan_line_location_ok_single_lot(self):
- """Scan to check if user scans a correct location for current line
-
- If there is only one single lot in the location, there is no
- ambiguity so we can use it.
- """
- self._simulate_batch_selected(self.batch, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- self._scan_line_ok(line, line.location_id.barcode)
-
- def test_scan_line_location_error_several_package(self):
- """Scan to check if user scans a correct location for current line
-
- If there are several packages in the location, user has to scan one.
- """
- self._simulate_batch_selected(self.batch, in_package=True)
- line = self.batch.picking_ids.move_line_ids
- location = line.location_id
- # add a second package in the location
- self._update_qty_in_location(
- location,
- self.product_b,
- 10,
- package=self.env["stock.quant.package"].create({}),
- )
- self._scan_line_error(
- line,
- location.barcode,
- {
- "message_type": "warning",
- "body": "Several packages found in Stock, please scan a package.",
- },
- )
-
- def test_scan_line_location_error_several_products(self):
- """Scan to check if user scans a correct location for current line
-
- If there are several products in the location, user has to scan one.
- """
- self._simulate_batch_selected(self.batch)
- line = self.batch.picking_ids.move_line_ids
- location = line.location_id
- # add a second product in the location
- self._update_qty_in_location(location, self.product_b, 10)
- self._scan_line_error(
- line,
- location.barcode,
- {
- "message_type": "warning",
- "body": "Several products found in Stock, please scan a product.",
- },
- )
-
- def test_scan_line_location_error_several_lots(self):
- """Scan to check if user scans a correct location for current line
-
- If there are several lots in the location, user has to scan one.
- """
- self._simulate_batch_selected(self.batch, in_lot=True)
- line = self.batch.picking_ids.move_line_ids
- location = line.location_id
- lot = self.env["stock.production.lot"].create(
- {"product_id": self.product_a.id, "company_id": self.env.company.id}
- )
- # add a second lot in the location
- self._update_qty_in_location(location, self.product_a, 10, lot=lot)
- self._scan_line_error(
- line,
- location.barcode,
- {
- "message_type": "warning",
- "body": "Several lots found in Stock, please scan a lot.",
- },
- )
-
- def test_scan_line_error_not_found(self):
- """Nothing found for the barcode"""
- self._simulate_batch_selected(self.batch, in_package=True)
- self._scan_line_error(
- self.batch.picking_ids.move_line_ids,
- "NO_EXISTING_BARCODE",
- {"message_type": "error", "body": "Barcode not found"},
- )
-
-
-class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase):
- """Tests covering the /scan_destination_pack endpoint
-
- After a batch has been selected and the user confirmed they are
- working on it, user picked the good, now they scan the location
- destination.
- """
-
- @classmethod
- def setUpClassBaseData(cls, *args, **kwargs):
- super().setUpClassBaseData(*args, **kwargs)
- cls.batch = cls._create_picking_batch(
- [
- [
- cls.BatchProduct(product=cls.product_a, quantity=10),
- cls.BatchProduct(product=cls.product_b, quantity=10),
- ],
- [cls.BatchProduct(product=cls.product_a, quantity=10)],
- ]
- )
- cls.one_line_picking = cls.batch.picking_ids.filtered(
- lambda picking: len(picking.move_lines) == 1
- )
- cls.two_lines_picking = cls.batch.picking_ids.filtered(
- lambda picking: len(picking.move_lines) == 2
- )
-
- cls.bin1 = cls.env["stock.quant.package"].create({})
- cls.bin2 = cls.env["stock.quant.package"].create({})
-
- cls._simulate_batch_selected(cls.batch)
-
- def test_scan_destination_pack_ok(self):
- """Happy path for scan destination package
-
- It sets the line in the pack for the full qty
- """
- line = self.batch.picking_ids.move_line_ids[0]
- next_line = self.batch.picking_ids.move_line_ids[1]
- qty_done = line.product_uom_qty
- response = self.service.dispatch(
- "scan_destination_pack",
- params={
- "picking_batch_id": self.batch.id,
- "move_line_id": line.id,
- "barcode": self.bin1.name,
- "quantity": qty_done,
- },
- )
- self.assertRecordValues(
- line, [{"qty_done": qty_done, "result_package_id": self.bin1.id}]
- )
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(next_line),
- message={
- "message_type": "success",
- "body": "{} {} put in {}".format(
- line.qty_done, line.product_id.display_name, self.bin1.name
- ),
- },
- )
-
- def test_scan_destination_pack_ok_last_line(self):
- """Happy path for scan destination package
-
- It sets the line in the pack for the full qty
- """
- self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1)
- self._set_dest_package_and_done(
- self.two_lines_picking.move_line_ids[0], self.bin2
- )
- # this is the only remaining line to pick
- line = self.two_lines_picking.move_line_ids[1]
- qty_done = line.product_uom_qty
- response = self.service.dispatch(
- "scan_destination_pack",
- params={
- "picking_batch_id": self.batch.id,
- "move_line_id": line.id,
- "barcode": self.bin2.name,
- "quantity": qty_done,
- },
- )
- self.assertRecordValues(
- line, [{"qty_done": qty_done, "result_package_id": self.bin2.id}]
- )
- data = self._data_for_batch(self.batch, self.packing_location)
- self.assert_response(
- response,
- # they reach the same destination so next state unload_all
- next_state="unload_all",
- data=data,
- )
-
- def test_scan_destination_pack_not_empty_same_picking(self):
- """Scan a destination package with move lines of same picking"""
- line1 = self.two_lines_picking.move_line_ids[0]
- line2 = self.two_lines_picking.move_line_ids[1]
- # we already scan and put the first line in bin1
- self._set_dest_package_and_done(line1, self.bin1)
- response = self.service.dispatch(
- "scan_destination_pack",
- params={
- "picking_batch_id": self.batch.id,
- "move_line_id": line2.id,
- # this bin is used for the same picking, should be allowed
- "barcode": self.bin1.name,
- "quantity": line2.product_uom_qty,
- },
- )
- self.assert_response(
- response,
- next_state="start_line",
- # we did not pick this line, so it should go there
- data=self._line_data(self.one_line_picking.move_line_ids),
- message=self.ANY,
- )
-
- def test_scan_destination_pack_not_empty_different_picking(self):
- """Scan a destination package with move lines of other picking"""
- # do as if the user already picked the first good (for another picking)
- # and put it in bin1
- self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1)
- line = self.two_lines_picking.move_line_ids[0]
- response = self.service.dispatch(
- "scan_destination_pack",
- params={
- "picking_batch_id": self.batch.id,
- "move_line_id": line.id,
- # this bin is used for the other picking
- "barcode": self.bin1.name,
- "quantity": line.product_uom_qty,
- },
- )
- self.assertRecordValues(line, [{"qty_done": 0, "result_package_id": False}])
- self.assert_response(
- response,
- next_state="scan_destination",
- data=self._line_data(line),
- message={
- "message_type": "error",
- "body": "The destination bin {} is not empty,"
- " please take another.".format(self.bin1.name),
- },
- )
-
- def test_scan_destination_pack_bin_not_found(self):
- """Scan a destination package that do not exist"""
- line = self.one_line_picking.move_line_ids
- response = self.service.dispatch(
- "scan_destination_pack",
- params={
- "picking_batch_id": self.batch.id,
- "move_line_id": line.id,
- # this bin is used for the other picking
- "barcode": "⌿",
- "quantity": line.product_uom_qty,
- },
- )
- self.assert_response(
- response,
- next_state="scan_destination",
- data=self._line_data(line),
- message={
- "message_type": "error",
- "body": "Bin {} doesn't exist".format("⌿"),
- },
- )
-
- def test_scan_destination_pack_quantity_more(self):
- """Pick more units than expected"""
- line = self.one_line_picking.move_line_ids
- response = self.service.dispatch(
- "scan_destination_pack",
- params={
- "picking_batch_id": self.batch.id,
- "move_line_id": line.id,
- "barcode": self.bin1.name,
- "quantity": line.product_uom_qty + 1,
- },
- )
- self.assert_response(
- response,
- next_state="scan_destination",
- data=self._line_data(line),
- message={
- "message_type": "error",
- "body": "You must not pick more than {} units.".format(
- line.product_uom_qty
- ),
- },
- )
-
- def test_scan_destination_pack_quantity_less(self):
- """Pick less units than expected"""
- line = self.one_line_picking.move_line_ids
- quant = self.env["stock.quant"].search(
- [
- ("location_id", "=", line.location_id.id),
- ("product_id", "=", line.product_id.id),
- ]
- )
- quant.ensure_one()
- self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}])
-
- # when we pick less quantity than expected, the line is split
- # and the user is proposed to pick the next line for the remaining
- # quantity
- response = self.service.dispatch(
- "scan_destination_pack",
- params={
- "picking_batch_id": self.batch.id,
- "move_line_id": line.id,
- "barcode": self.bin1.name,
- "quantity": line.product_uom_qty - 3,
- },
- )
- new_line = self.one_line_picking.move_line_ids - line
-
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(new_line),
- message={
- "message_type": "success",
- "body": "{} {} put in {}".format(
- line.qty_done, line.product_id.display_name, self.bin1.name
- ),
- },
- )
-
- self.assertRecordValues(
- line,
- [{"qty_done": 7, "result_package_id": self.bin1.id, "product_uom_qty": 7}],
- )
- self.assertRecordValues(
- new_line,
- [{"qty_done": 0, "result_package_id": False, "product_uom_qty": 3}],
- )
- # the reserved quantity on the quant must stay the same
- self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}])
-
- def test_scan_destination_pack_zero_check_activated(self):
- """Location will be emptied, have to go to zero check"""
- # ensure that the location used for the test will contain only what we want
- self.zero_check_location = (
- self.env["stock.location"]
- .sudo()
- .create(
- {
- "name": "ZeroCheck",
- "location_id": self.stock_location.id,
- "barcode": "ZEROCHECK",
- }
- )
- )
- line = self.one_line_picking.move_line_ids
- location, product, qty = (
- self.zero_check_location,
- line.product_id,
- line.product_uom_qty,
- )
- self.one_line_picking.do_unreserve()
-
- # ensure we have activated the zero check
- self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = True
- # Update the quantity in the location to be equal to the line's
- # so when scan_destination_pack sets the qty_done, the planned
- # qty should be zero and trigger a zero check
- self._update_qty_in_location(location, product, qty)
- # Reserve goods (now the move line has the expected source location)
- self.one_line_picking.move_lines.location_id = location
- self.one_line_picking.action_assign()
- line = self.one_line_picking.move_line_ids
- response = self.service.dispatch(
- "scan_destination_pack",
- params={
- "picking_batch_id": self.batch.id,
- "move_line_id": line.id,
- "barcode": self.bin1.name,
- "quantity": line.product_uom_qty,
- },
- )
-
- self.assert_response(
- response,
- next_state="zero_check",
- data={
- "id": line.id,
- "location_src": self.data.location(line.location_id),
- "batch": self.data.picking_batch(self.batch),
- },
- )
-
- def test_scan_destination_pack_zero_check_disabled(self):
- """Location will be emptied, no zero check, continue"""
- line = self.one_line_picking.move_line_ids
- # ensure we have deactivated the zero check
- self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = False
- # Update the quantity in the location to be equal to the line's
- # so when scan_destination_pack sets the qty_done, the planned
- # qty should be zero and trigger a zero check
- self._update_qty_in_location(
- line.location_id, line.product_id, line.product_uom_qty
- )
- response = self.service.dispatch(
- "scan_destination_pack",
- params={
- "picking_batch_id": self.batch.id,
- "move_line_id": line.id,
- "barcode": self.bin1.name,
- "quantity": line.product_uom_qty,
- },
- )
-
- next_line = self.two_lines_picking.move_line_ids[0]
- # continue to the next one, no zero check
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(next_line),
- message={
- "message_type": "success",
- "body": "{} {} put in {}".format(
- line.qty_done, line.product_id.display_name, self.bin1.name
- ),
- },
- )
-
-
-class ClusterPickingIsZeroCase(ClusterPickingCommonCase):
- """Tests covering the /is_zero endpoint
-
- After a line has been scanned, if the location is empty, the
- client application is redirected to the "zero_check" state,
- where the user has to confirm or not that the location is empty.
- When the location is empty, there is nothing to do, but when it
- in fact not empty, a draft inventory must be created for the
- product so someone can verify.
- """
-
- @classmethod
- def setUpClassBaseData(cls, *args, **kwargs):
- super().setUpClassBaseData(*args, **kwargs)
- cls.batch = cls._create_picking_batch(
- [
- [
- cls.BatchProduct(product=cls.product_a, quantity=10),
- cls.BatchProduct(product=cls.product_b, quantity=10),
- ]
- ]
- )
- cls.picking = cls.batch.picking_ids
- cls._simulate_batch_selected(cls.batch)
-
- cls.line = cls.picking.move_line_ids[0]
- cls.next_line = cls.picking.move_line_ids[1]
- cls.bin1 = cls.env["stock.quant.package"].create({})
- cls._update_qty_in_location(
- cls.line.location_id, cls.line.product_id, cls.line.product_uom_qty
- )
- # we already scan and put the first line in bin1, at this point the
- # system see the location is empty and reach "zero_check"
- cls._set_dest_package_and_done(cls.line, cls.bin1)
-
- def test_is_zero_is_empty(self):
- """call /is_zero confirming it's empty"""
- response = self.service.dispatch(
- "is_zero",
- params={
- "picking_batch_id": self.batch.id,
- "move_line_id": self.line.id,
- "zero": True,
- },
- )
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(self.next_line),
- message={
- "message_type": "success",
- "body": "{} {} put in {}".format(
- self.line.qty_done,
- self.line.product_id.display_name,
- self.bin1.name,
- ),
- },
- )
-
- def test_is_zero_is_not_empty(self):
- """call /is_zero not confirming it's empty"""
- response = self.service.dispatch(
- "is_zero",
- params={
- "picking_batch_id": self.batch.id,
- "move_line_id": self.line.id,
- "zero": False,
- },
- )
- inventory = self.env["stock.inventory"].search(
- [
- ("location_ids", "in", self.line.location_id.id),
- ("product_ids", "in", self.line.product_id.id),
- ("state", "=", "draft"),
- ]
- )
- self.assertTrue(inventory)
- self.assertEqual(
- inventory.name,
- "Zero check issue on location Stock ({})".format(self.picking.name),
- )
- self.assert_response(
- response,
- next_state="start_line",
- data=self._line_data(self.next_line),
- message={
- "message_type": "success",
- "body": "{} {} put in {}".format(
- self.line.qty_done,
- self.line.product_id.display_name,
- self.bin1.name,
- ),
- },
- )
diff --git a/shopfloor/tests/test_cluster_picking_scan_destination.py b/shopfloor/tests/test_cluster_picking_scan_destination.py
new file mode 100644
index 0000000000..4b7ab39305
--- /dev/null
+++ b/shopfloor/tests/test_cluster_picking_scan_destination.py
@@ -0,0 +1,336 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from .test_cluster_picking_base import ClusterPickingCommonCase
+
+
+class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase):
+ """Tests covering the /scan_destination_pack endpoint
+
+ After a batch has been selected and the user confirmed they are
+ working on it, user picked the good, now they scan the location
+ destination.
+ """
+
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.batch = cls._create_picking_batch(
+ [
+ [
+ cls.BatchProduct(product=cls.product_a, quantity=10),
+ cls.BatchProduct(product=cls.product_b, quantity=10),
+ ],
+ [cls.BatchProduct(product=cls.product_a, quantity=10)],
+ ]
+ )
+ cls.one_line_picking = cls.batch.picking_ids.filtered(
+ lambda picking: len(picking.move_lines) == 1
+ )
+ cls.two_lines_picking = cls.batch.picking_ids.filtered(
+ lambda picking: len(picking.move_lines) == 2
+ )
+
+ cls.bin1 = cls.env["stock.quant.package"].create({})
+ cls.bin2 = cls.env["stock.quant.package"].create({})
+
+ cls._simulate_batch_selected(cls.batch)
+
+ def test_scan_destination_pack_ok(self):
+ """Happy path for scan destination package
+
+ It sets the line in the pack for the full qty
+ """
+ line = self.batch.picking_ids.move_line_ids[0]
+ next_line = self.batch.picking_ids.move_line_ids[1]
+ qty_done = line.product_uom_qty
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": line.id,
+ "barcode": self.bin1.name,
+ "quantity": qty_done,
+ },
+ )
+ self.assertRecordValues(
+ line, [{"qty_done": qty_done, "result_package_id": self.bin1.id}]
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(next_line),
+ message={
+ "message_type": "success",
+ "body": "{} {} put in {}".format(
+ line.qty_done, line.product_id.display_name, self.bin1.name
+ ),
+ },
+ )
+
+ def test_scan_destination_pack_ok_last_line(self):
+ """Happy path for scan destination package
+
+ It sets the line in the pack for the full qty
+ """
+ self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1)
+ self._set_dest_package_and_done(
+ self.two_lines_picking.move_line_ids[0], self.bin2
+ )
+ # this is the only remaining line to pick
+ line = self.two_lines_picking.move_line_ids[1]
+ qty_done = line.product_uom_qty
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": line.id,
+ "barcode": self.bin2.name,
+ "quantity": qty_done,
+ },
+ )
+ self.assertRecordValues(
+ line, [{"qty_done": qty_done, "result_package_id": self.bin2.id}]
+ )
+ data = self._data_for_batch(self.batch, self.packing_location)
+ self.assert_response(
+ response,
+ # they reach the same destination so next state unload_all
+ next_state="unload_all",
+ data=data,
+ )
+
+ def test_scan_destination_pack_not_empty_same_picking(self):
+ """Scan a destination package with move lines of same picking"""
+ line1 = self.two_lines_picking.move_line_ids[0]
+ line2 = self.two_lines_picking.move_line_ids[1]
+ # we already scan and put the first line in bin1
+ self._set_dest_package_and_done(line1, self.bin1)
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": line2.id,
+ # this bin is used for the same picking, should be allowed
+ "barcode": self.bin1.name,
+ "quantity": line2.product_uom_qty,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ # we did not pick this line, so it should go there
+ data=self._line_data(self.one_line_picking.move_line_ids),
+ message=self.ANY,
+ )
+
+ def test_scan_destination_pack_not_empty_different_picking(self):
+ """Scan a destination package with move lines of other picking"""
+ # do as if the user already picked the first good (for another picking)
+ # and put it in bin1
+ self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1)
+ line = self.two_lines_picking.move_line_ids[0]
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": line.id,
+ # this bin is used for the other picking
+ "barcode": self.bin1.name,
+ "quantity": line.product_uom_qty,
+ },
+ )
+ self.assertRecordValues(line, [{"qty_done": 0, "result_package_id": False}])
+ self.assert_response(
+ response,
+ next_state="scan_destination",
+ data=self._line_data(line),
+ message={
+ "message_type": "error",
+ "body": "The destination bin {} is not empty,"
+ " please take another.".format(self.bin1.name),
+ },
+ )
+
+ def test_scan_destination_pack_bin_not_found(self):
+ """Scan a destination package that do not exist"""
+ line = self.one_line_picking.move_line_ids
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": line.id,
+ # this bin is used for the other picking
+ "barcode": "⌿",
+ "quantity": line.product_uom_qty,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="scan_destination",
+ data=self._line_data(line),
+ message={
+ "message_type": "error",
+ "body": "Bin {} doesn't exist".format("⌿"),
+ },
+ )
+
+ def test_scan_destination_pack_quantity_more(self):
+ """Pick more units than expected"""
+ line = self.one_line_picking.move_line_ids
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": line.id,
+ "barcode": self.bin1.name,
+ "quantity": line.product_uom_qty + 1,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="scan_destination",
+ data=self._line_data(line),
+ message={
+ "message_type": "error",
+ "body": "You must not pick more than {} units.".format(
+ line.product_uom_qty
+ ),
+ },
+ )
+
+ def test_scan_destination_pack_quantity_less(self):
+ """Pick less units than expected"""
+ line = self.one_line_picking.move_line_ids
+ quant = self.env["stock.quant"].search(
+ [
+ ("location_id", "=", line.location_id.id),
+ ("product_id", "=", line.product_id.id),
+ ]
+ )
+ quant.ensure_one()
+ self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}])
+
+ # when we pick less quantity than expected, the line is split
+ # and the user is proposed to pick the next line for the remaining
+ # quantity
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": line.id,
+ "barcode": self.bin1.name,
+ "quantity": line.product_uom_qty - 3,
+ },
+ )
+ new_line = self.one_line_picking.move_line_ids - line
+
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(new_line),
+ message={
+ "message_type": "success",
+ "body": "{} {} put in {}".format(
+ line.qty_done, line.product_id.display_name, self.bin1.name
+ ),
+ },
+ )
+
+ self.assertRecordValues(
+ line,
+ [{"qty_done": 7, "result_package_id": self.bin1.id, "product_uom_qty": 7}],
+ )
+ self.assertRecordValues(
+ new_line,
+ [{"qty_done": 0, "result_package_id": False, "product_uom_qty": 3}],
+ )
+ # the reserved quantity on the quant must stay the same
+ self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}])
+
+ def test_scan_destination_pack_zero_check_activated(self):
+ """Location will be emptied, have to go to zero check"""
+ # ensure that the location used for the test will contain only what we want
+ self.zero_check_location = (
+ self.env["stock.location"]
+ .sudo()
+ .create(
+ {
+ "name": "ZeroCheck",
+ "location_id": self.stock_location.id,
+ "barcode": "ZEROCHECK",
+ }
+ )
+ )
+ line = self.one_line_picking.move_line_ids
+ location, product, qty = (
+ self.zero_check_location,
+ line.product_id,
+ line.product_uom_qty,
+ )
+ self.one_line_picking.do_unreserve()
+
+ # ensure we have activated the zero check
+ self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = True
+ # Update the quantity in the location to be equal to the line's
+ # so when scan_destination_pack sets the qty_done, the planned
+ # qty should be zero and trigger a zero check
+ self._update_qty_in_location(location, product, qty)
+ # Reserve goods (now the move line has the expected source location)
+ self.one_line_picking.move_lines.location_id = location
+ self.one_line_picking.action_assign()
+ line = self.one_line_picking.move_line_ids
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": line.id,
+ "barcode": self.bin1.name,
+ "quantity": line.product_uom_qty,
+ },
+ )
+
+ self.assert_response(
+ response,
+ next_state="zero_check",
+ data={
+ "id": line.id,
+ "location_src": self.data.location(line.location_id),
+ "batch": self.data.picking_batch(self.batch),
+ },
+ )
+
+ def test_scan_destination_pack_zero_check_disabled(self):
+ """Location will be emptied, no zero check, continue"""
+ line = self.one_line_picking.move_line_ids
+ # ensure we have deactivated the zero check
+ self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = False
+ # Update the quantity in the location to be equal to the line's
+ # so when scan_destination_pack sets the qty_done, the planned
+ # qty should be zero and trigger a zero check
+ self._update_qty_in_location(
+ line.location_id, line.product_id, line.product_uom_qty
+ )
+ response = self.service.dispatch(
+ "scan_destination_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "move_line_id": line.id,
+ "barcode": self.bin1.name,
+ "quantity": line.product_uom_qty,
+ },
+ )
+
+ next_line = self.two_lines_picking.move_line_ids[0]
+ # continue to the next one, no zero check
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(next_line),
+ message={
+ "message_type": "success",
+ "body": "{} {} put in {}".format(
+ line.qty_done, line.product_id.display_name, self.bin1.name
+ ),
+ },
+ )
diff --git a/shopfloor/tests/test_cluster_picking_scan_line.py b/shopfloor/tests/test_cluster_picking_scan_line.py
new file mode 100644
index 0000000000..67a6e33e67
--- /dev/null
+++ b/shopfloor/tests/test_cluster_picking_scan_line.py
@@ -0,0 +1,273 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from .test_cluster_picking_base import ClusterPickingLineCommonCase
+
+
+class ClusterPickingScanLineCase(ClusterPickingLineCommonCase):
+ """Tests covering the /scan_line endpoint
+
+ After a batch has been selected and the user confirmed they are
+ working on it.
+
+ User scans something and the scan_line endpoints validates they
+ scanned the proper thing to pick.
+ """
+
+ def _scan_line_ok(self, line, scanned):
+ batch = line.picking_id.batch_id
+ response = self.service.dispatch(
+ "scan_line",
+ params={
+ "picking_batch_id": batch.id,
+ "move_line_id": line.id,
+ "barcode": scanned,
+ },
+ )
+ self.assert_response(
+ response, next_state="scan_destination", data=self._line_data(line)
+ )
+
+ def _scan_line_error(self, line, scanned, message):
+ batch = line.picking_id.batch_id
+ response = self.service.dispatch(
+ "scan_line",
+ params={
+ "picking_batch_id": batch.id,
+ "move_line_id": line.id,
+ "barcode": scanned,
+ },
+ )
+ self.assert_response(
+ response,
+ next_state="start_line",
+ data=self._line_data(line),
+ message=message,
+ )
+
+ def test_scan_line_pack_ok(self):
+ """Scan to check if user picks the correct pack for current line"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.package_id.name)
+
+ def test_scan_line_product_ok(self):
+ """Scan to check if user picks the correct product for current line"""
+ self._simulate_batch_selected(self.batch)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.product_id.barcode)
+
+ def test_scan_line_lot_ok(self):
+ """Scan to check if user picks the correct lot for current line"""
+ self.product_a.tracking = "lot"
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.lot_id.name)
+
+ def test_scan_line_serial_ok(self):
+ """Scan to check if user picks the correct serial for current line"""
+ self.product_a.tracking = "serial"
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.lot_id.name)
+
+ def test_scan_line_error_product_tracked(self):
+ """Scan a product tracked by lot, must scan the lot"""
+ self.product_a.tracking = "lot"
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_error(
+ line,
+ line.product_id.barcode,
+ {
+ "message_type": "warning",
+ "body": "Product tracked by lot, please scan one.",
+ },
+ )
+
+ def test_scan_line_product_error_several_packages(self):
+ """When we scan a product which is in more than one package, error"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ # create a second move line for the same product in a different
+ # package
+ move = line.move_id.copy()
+ self._fill_stock_for_moves(move, in_package=True)
+ move._action_confirm(merge=False)
+ move._action_assign()
+
+ self._scan_line_error(
+ line,
+ move.product_id.barcode,
+ {
+ "message_type": "warning",
+ "body": "This product is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
+
+ def test_scan_line_product_error_in_one_package_and_unit(self):
+ """When we scan a product which is in a package and as raw, error"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ # create a second move line for the same product in a different
+ # package
+ move = line.move_id.copy()
+ self._fill_stock_for_moves(move)
+ move._action_confirm(merge=False)
+ move._action_assign()
+
+ self._scan_line_error(
+ line,
+ move.product_id.barcode,
+ {
+ "message_type": "warning",
+ "body": "This product is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
+
+ def test_scan_line_lot_error_several_packages(self):
+ """When we scan a lot which is in more than one package, error"""
+ self._simulate_batch_selected(self.batch, in_package=True, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ # create a second move line for the same product in a different
+ # package
+ move = line.move_id.copy()
+ self._fill_stock_for_moves(move, in_lot=line.lot_id)
+ move._action_confirm(merge=False)
+ move._action_assign()
+
+ self._scan_line_error(
+ line,
+ line.lot_id.name,
+ {
+ "message_type": "warning",
+ "body": "This lot is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
+
+ def test_scan_line_lot_error_in_one_package_and_unit(self):
+ """When we scan a lot which is in a package and as raw, error"""
+ self._simulate_batch_selected(self.batch, in_package=True, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ # create a second move line for the same product in a different
+ # package
+ move = line.move_id.copy()
+ self._fill_stock_for_moves(move, in_lot=line.lot_id)
+ move._action_confirm(merge=False)
+ move._action_assign()
+ self._scan_line_error(
+ line,
+ line.lot_id.name,
+ {
+ "message_type": "warning",
+ "body": "This lot is part of multiple"
+ " packages, please scan a package.",
+ },
+ )
+
+ def test_scan_line_location_ok_single_package(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there is only one single package in the location, there is no
+ ambiguity so we can use it.
+ """
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.location_id.barcode)
+
+ def test_scan_line_location_ok_single_product(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there is only one single product in the location, there is no
+ ambiguity so we can use it.
+ """
+ self._simulate_batch_selected(self.batch)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.location_id.barcode)
+
+ def test_scan_line_location_ok_single_lot(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there is only one single lot in the location, there is no
+ ambiguity so we can use it.
+ """
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ self._scan_line_ok(line, line.location_id.barcode)
+
+ def test_scan_line_location_error_several_package(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there are several packages in the location, user has to scan one.
+ """
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ location = line.location_id
+ # add a second package in the location
+ self._update_qty_in_location(
+ location,
+ self.product_b,
+ 10,
+ package=self.env["stock.quant.package"].create({}),
+ )
+ self._scan_line_error(
+ line,
+ location.barcode,
+ {
+ "message_type": "warning",
+ "body": "Several packages found in Stock, please scan a package.",
+ },
+ )
+
+ def test_scan_line_location_error_several_products(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there are several products in the location, user has to scan one.
+ """
+ self._simulate_batch_selected(self.batch)
+ line = self.batch.picking_ids.move_line_ids
+ location = line.location_id
+ # add a second product in the location
+ self._update_qty_in_location(location, self.product_b, 10)
+ self._scan_line_error(
+ line,
+ location.barcode,
+ {
+ "message_type": "warning",
+ "body": "Several products found in Stock, please scan a product.",
+ },
+ )
+
+ def test_scan_line_location_error_several_lots(self):
+ """Scan to check if user scans a correct location for current line
+
+ If there are several lots in the location, user has to scan one.
+ """
+ self._simulate_batch_selected(self.batch, in_lot=True)
+ line = self.batch.picking_ids.move_line_ids
+ location = line.location_id
+ lot = self.env["stock.production.lot"].create(
+ {"product_id": self.product_a.id, "company_id": self.env.company.id}
+ )
+ # add a second lot in the location
+ self._update_qty_in_location(location, self.product_a, 10, lot=lot)
+ self._scan_line_error(
+ line,
+ location.barcode,
+ {
+ "message_type": "warning",
+ "body": "Several lots found in Stock, please scan a lot.",
+ },
+ )
+
+ def test_scan_line_error_not_found(self):
+ """Nothing found for the barcode"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ self._scan_line_error(
+ self.batch.picking_ids.move_line_ids,
+ "NO_EXISTING_BARCODE",
+ {"message_type": "error", "body": "Barcode not found"},
+ )
From db9ee5d27025795f5440385c6abe0c398486085a Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 1 Dec 2020 12:13:54 +0100
Subject: [PATCH 461/940] shopfloor: cluster picking fix scan product found
multiple times
When scanning a product barcode you could have the same product in different lines.
Till this change an exception was raised and the user asked
to scan a specific package or lot.
This is not needed if the product is not the same location as the current move line.
---
shopfloor/services/cluster_picking.py | 6 +++---
.../tests/test_cluster_picking_scan_line.py | 19 +++++++++++++++++--
2 files changed, 20 insertions(+), 5 deletions(-)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 9e91d91df0..620751c6d5 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -468,10 +468,10 @@ def _scan_line_by_product(self, picking, move_line, product):
move_line, message=self.msg_store.scan_lot_on_product_tracked_by_lot()
)
- # if we scanned a product and it's part of several packages, we can't be
- # sure the user scanned the correct one, in such case, ask to scan a package
+ # If scanned product is part of several packages in the same location,
+ # we can't be sure it's the correct one, in such case, ask to scan a package
other_product_lines = picking.move_line_ids.filtered(
- lambda l: l.product_id == product
+ lambda l: l.product_id == product and l.location_id == move_line.location_id
)
packages = other_product_lines.mapped("package_id")
# Do not use mapped here: we want to see if we have more than one package,
diff --git a/shopfloor/tests/test_cluster_picking_scan_line.py b/shopfloor/tests/test_cluster_picking_scan_line.py
index 67a6e33e67..626779a7f9 100644
--- a/shopfloor/tests/test_cluster_picking_scan_line.py
+++ b/shopfloor/tests/test_cluster_picking_scan_line.py
@@ -106,8 +106,8 @@ def test_scan_line_product_error_several_packages(self):
},
)
- def test_scan_line_product_error_in_one_package_and_unit(self):
- """When we scan a product which is in a package and as raw, error"""
+ def test_scan_line_product_error_in_one_package_and_raw_same_location(self):
+ """Scan product which is both in a package and as raw in same location"""
self._simulate_batch_selected(self.batch, in_package=True)
line = self.batch.picking_ids.move_line_ids
# create a second move line for the same product in a different
@@ -116,6 +116,7 @@ def test_scan_line_product_error_in_one_package_and_unit(self):
self._fill_stock_for_moves(move)
move._action_confirm(merge=False)
move._action_assign()
+ move.move_line_ids[0].package_id = None
self._scan_line_error(
line,
@@ -127,6 +128,20 @@ def test_scan_line_product_error_in_one_package_and_unit(self):
},
)
+ def test_scan_line_product_error_in_one_package_and_raw_different_location(self):
+ """Scan product which is both in a package and as raw in another location"""
+ self._simulate_batch_selected(self.batch, in_package=True)
+ line = self.batch.picking_ids.move_line_ids
+ # create a second move line for the same product in a different
+ # package
+ move = line.move_id.copy()
+ self._fill_stock_for_moves(move)
+ move._action_confirm(merge=False)
+ move._action_assign()
+ move.move_line_ids[0].package_id = None
+ move.move_line_ids[0].location_id = line.location_id.copy()
+ self._scan_line_ok(line, move.product_id.barcode)
+
def test_scan_line_lot_error_several_packages(self):
"""When we scan a lot which is in more than one package, error"""
self._simulate_batch_selected(self.batch, in_package=True, in_lot=True)
From 9f265320e1484bb271f17da444ce7bb3cc785b09 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Tue, 1 Dec 2020 14:49:19 +0100
Subject: [PATCH 462/940] shopfloor: fix counters consider ready pickings only
---
shopfloor/actions/move_line_search.py | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/shopfloor/actions/move_line_search.py b/shopfloor/actions/move_line_search.py
index 21d11c23bc..2b5adbba5c 100644
--- a/shopfloor/actions/move_line_search.py
+++ b/shopfloor/actions/move_line_search.py
@@ -28,6 +28,7 @@ def _search_move_lines_by_location_domain(
product=None,
lot=None,
match_user=False,
+ picking_ready=True,
):
domain = [
("location_id", "child_of", locations.ids),
@@ -51,6 +52,8 @@ def _search_move_lines_by_location_domain(
("shopfloor_user_id", "=", False),
("shopfloor_user_id", "=", self.env.uid),
]
+ if picking_ready:
+ domain += [("picking_id.state", "=", "assigned")]
return domain
def search_move_lines_by_location(
@@ -63,11 +66,18 @@ def search_move_lines_by_location(
order="priority",
match_user=False,
sort_keys_func=None,
+ picking_ready=True,
):
"""Find lines that potentially need work in given locations."""
move_lines = self.env["stock.move.line"].search(
self._search_move_lines_by_location_domain(
- locations, picking_type, package, product, lot, match_user=match_user
+ locations,
+ picking_type,
+ package,
+ product,
+ lot,
+ match_user=match_user,
+ picking_ready=picking_ready,
)
)
sort_keys_func = sort_keys_func or self._sort_key_move_lines(order)
From 39bd2a67c69114ec42915cf44e5c4183f7a97193 Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Thu, 3 Dec 2020 12:53:22 +0100
Subject: [PATCH 463/940] shopfloor bump 13.0.1.5.1
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 4ed966e3a5..9735277ec7 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.5.0",
+ "version": "13.0.1.5.1",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From fe7eb5fcd7d7311ee53b11554a2b48e723333f36 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20BEAU?=
Date: Thu, 10 Dec 2020 11:15:40 +0100
Subject: [PATCH 464/940] [IMP] black, isort, prettier
---
shopfloor/actions/data.py | 5 +-
shopfloor/actions/data_detail.py | 3 +-
shopfloor/models/shopfloor_log.py | 3 +-
shopfloor/models/stock_location.py | 3 +-
shopfloor/models/stock_picking.py | 3 +-
shopfloor/models/stock_picking_type.py | 4 +-
shopfloor/models/stock_quant.py | 2 +-
shopfloor/models/stock_quant_package.py | 3 +-
shopfloor/services/checkout.py | 4 +-
shopfloor/services/cluster_picking.py | 3 +-
shopfloor/services/delivery.py | 9 ++-
shopfloor/services/forms/form_mixin.py | 3 +-
shopfloor/services/menu.py | 3 +-
shopfloor/services/single_pack_transfer.py | 12 +++-
shopfloor/tests/test_checkout_cancel_line.py | 6 +-
shopfloor/tests/test_checkout_no_package.py | 6 +-
.../tests/test_cluster_picking_select.py | 28 ++++++--
shopfloor/tests/test_cluster_picking_skip.py | 3 +-
.../tests/test_cluster_picking_stock_issue.py | 3 +-
.../tests/test_cluster_picking_unload.py | 32 ++++++---
shopfloor/tests/test_delivery_done.py | 9 ++-
.../tests/test_delivery_list_stock_picking.py | 9 ++-
shopfloor/tests/test_delivery_scan_deliver.py | 18 +++--
.../test_location_content_transfer_base.py | 5 +-
.../test_location_content_transfer_mix.py | 6 +-
.../test_location_content_transfer_putaway.py | 7 +-
...ransfer_set_destination_package_or_line.py | 18 +++--
.../test_location_content_transfer_single.py | 34 ++++++----
.../test_location_content_transfer_start.py | 7 +-
shopfloor/tests/test_scan_anything.py | 3 +-
.../test_single_pack_transfer_putaway.py | 4 +-
shopfloor/tests/test_user.py | 3 +-
shopfloor/tests/test_zone_picking_base.py | 46 +++++++++++--
.../test_zone_picking_change_pack_lot.py | 9 ++-
.../tests/test_zone_picking_select_line.py | 65 ++++++++++++++-----
.../test_zone_picking_select_picking_type.py | 6 +-
.../test_zone_picking_set_line_destination.py | 19 ++++--
shopfloor/tests/test_zone_picking_start.py | 28 +++++---
.../tests/test_zone_picking_stock_issue.py | 29 +++++++--
.../tests/test_zone_picking_unload_all.py | 14 ++--
...est_zone_picking_unload_set_destination.py | 6 +-
.../tests/test_zone_picking_unload_single.py | 11 +++-
.../tests/test_zone_picking_zero_check.py | 22 +++++--
43 files changed, 370 insertions(+), 146 deletions(-)
diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py
index 5301f90245..f1d3bbb644 100644
--- a/shopfloor/actions/data.py
+++ b/shopfloor/actions/data.py
@@ -160,7 +160,10 @@ def _move_line_parser(self):
("lot_id:lot", self._lot_parser),
("location_id:location_src", self._location_parser),
("location_dest_id:location_dest", self._location_parser),
- ("move_id:priority", lambda rec, fname: rec.move_id.priority or "",),
+ (
+ "move_id:priority",
+ lambda rec, fname: rec.move_id.priority or "",
+ ),
]
def package_level(self, record, **kw):
diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py
index e3024da604..ddd7527802 100644
--- a/shopfloor/actions/data_detail.py
+++ b/shopfloor/actions/data_detail.py
@@ -6,8 +6,7 @@
class DataDetailAction(Component):
- """Provide extra data on top of data action.
- """
+ """Provide extra data on top of data action."""
_name = "shopfloor.data.detail.action"
_inherit = "shopfloor.data.action"
diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py
index 110f04cf08..9e94415f01 100644
--- a/shopfloor/models/shopfloor_log.py
+++ b/shopfloor/models/shopfloor_log.py
@@ -33,7 +33,8 @@ class ShopfloorLog(models.Model):
exception_name = fields.Char(readonly=True, string="Exception")
exception_message = fields.Text(readonly=True)
state = fields.Selection(
- selection=[("success", "Success"), ("failed", "Failed")], readonly=True,
+ selection=[("success", "Success"), ("failed", "Failed")],
+ readonly=True,
)
severity = fields.Selection(
selection=[
diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py
index b66146f7ae..6b8c3cd921 100644
--- a/shopfloor/models/stock_location.py
+++ b/shopfloor/models/stock_location.py
@@ -19,7 +19,8 @@ class StockLocation(models.Model):
comodel_name="stock.move.line", inverse_name="location_id", readonly=True
)
reserved_move_line_ids = fields.One2many(
- comodel_name="stock.move.line", compute="_compute_reserved_move_lines",
+ comodel_name="stock.move.line",
+ compute="_compute_reserved_move_lines",
)
def is_sublocation_of(self, others, func=any):
diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py
index 35cb047cc9..e37e3ff67d 100644
--- a/shopfloor/models/stock_picking.py
+++ b/shopfloor/models/stock_picking.py
@@ -18,7 +18,8 @@ class StockPicking(models.Model):
related="picking_type_id.shopfloor_display_packing_info",
)
shopfloor_packing_info = fields.Text(
- string="Packing information", related="partner_id.shopfloor_packing_info",
+ string="Packing information",
+ related="partner_id.shopfloor_packing_info",
)
@api.depends(
diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py
index 2525db4aaa..984a69cb8c 100644
--- a/shopfloor/models/stock_picking_type.py
+++ b/shopfloor/models/stock_picking_type.py
@@ -7,7 +7,9 @@ class StockPickingType(models.Model):
_inherit = "stock.picking.type"
shopfloor_menu_ids = fields.Many2many(
- comodel_name="shopfloor.menu", string="Shopfloor Menus", readonly=True,
+ comodel_name="shopfloor.menu",
+ string="Shopfloor Menus",
+ readonly=True,
)
shopfloor_zero_check = fields.Boolean(
string="Activate Zero Check",
diff --git a/shopfloor/models/stock_quant.py b/shopfloor/models/stock_quant.py
index d20af1278e..bc17dcd29b 100644
--- a/shopfloor/models/stock_quant.py
+++ b/shopfloor/models/stock_quant.py
@@ -7,7 +7,7 @@ class StockQuant(models.Model):
_inherit = "stock.quant"
def _is_inventory_mode(self):
- """ Used to control whether a quant was written on or created during an
+ """Used to control whether a quant was written on or created during an
"inventory session", meaning a mode where we need to create the stock.move
record necessary to be consistent with the `inventory_quantity` field.
"""
diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py
index 8f7ee23462..c155115bca 100644
--- a/shopfloor/models/stock_quant_package.py
+++ b/shopfloor/models/stock_quant_package.py
@@ -20,7 +20,8 @@ class StockQuantPackage(models.Model):
)
# TODO: review other fields
reserved_move_line_ids = fields.One2many(
- comodel_name="stock.move.line", compute="_compute_reserved_move_lines",
+ comodel_name="stock.move.line",
+ compute="_compute_reserved_move_lines",
)
def _get_reserved_move_lines(self):
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index fe45bd74a1..7a156ec2d7 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -513,7 +513,9 @@ def _change_line_qty(
new_line = self.env["stock.move.line"]
if qty_done > 0:
new_line, qty_check = move_line._split_qty_to_be_done(
- qty_done, split_partial=True, result_package_id=False,
+ qty_done,
+ split_partial=True,
+ result_package_id=False,
)
if qty_check == "greater":
qty_done = move_line.product_uom_qty
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index 620751c6d5..e5db7fd007 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -885,7 +885,8 @@ def change_pack_lot(self, picking_batch_id, move_line_id, barcode):
)
return self._response_for_change_pack_lot(
- move_line, message=self.msg_store.no_package_or_lot_for_barcode(barcode),
+ move_line,
+ message=self.msg_store.no_package_or_lot_for_barcode(barcode),
)
def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 476d97073a..f8aea169ea 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -415,7 +415,8 @@ def set_qty_done_line(self, picking_id, move_line_id):
)
return self._response_for_deliver(picking)
return self._response_for_deliver(
- picking=picking, message=self.msg_store.record_not_found(),
+ picking=picking,
+ message=self.msg_store.record_not_found(),
)
def reset_qty_done_pack(self, picking_id, package_id):
@@ -474,7 +475,8 @@ def reset_qty_done_line(self, picking_id, move_line_id):
self._reset_lines(line)
return self._response_for_deliver(picking)
return self._response_for_deliver(
- picking=picking, message=self.msg_store.record_not_found(),
+ picking=picking,
+ message=self.msg_store.record_not_found(),
)
def done(self, picking_id, confirm=False):
@@ -511,7 +513,8 @@ def done(self, picking_id, confirm=False):
message=self.msg_store.transfer_complete(picking)
)
return self._response_for_confirm_done(
- picking, message=self.msg_store.transfer_confirm_done(),
+ picking,
+ message=self.msg_store.transfer_confirm_done(),
)
diff --git a/shopfloor/services/forms/form_mixin.py b/shopfloor/services/forms/form_mixin.py
index 1389082c80..f0d1d4aaa7 100644
--- a/shopfloor/services/forms/form_mixin.py
+++ b/shopfloor/services/forms/form_mixin.py
@@ -7,8 +7,7 @@
class ShopfloorFormMixin(AbstractComponent):
- """Allow to edit records.
- """
+ """Allow to edit records."""
_inherit = "base.shopfloor.service"
_name = "shopfloor.form.mixin"
diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py
index cb82e53517..02a6a90ede 100644
--- a/shopfloor/services/menu.py
+++ b/shopfloor/services/menu.py
@@ -65,8 +65,7 @@ def _convert_one_record(self, record):
return values
def _get_move_line_counters(self, record):
- """Lookup for all lines per menu item and compute counters.
- """
+ """Lookup for all lines per menu item and compute counters."""
# TODO: maybe to be improved w/ raw SQL as this run for each menu item
# and it's called every time the menu is opened/gets refreshed
move_line_search = self.actions_for(
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index 4bddbd3bcc..b0c7a6faa2 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -34,14 +34,22 @@ def _response_for_start(self, message=None, popup=None):
def _response_for_confirm_start(self, package_level, message=None):
data = self._data_after_package_scanned(package_level)
data["confirmation_required"] = True
- return self._response(next_state="start", data=data, message=message,)
+ return self._response(
+ next_state="start",
+ data=data,
+ message=message,
+ )
def _response_for_scan_location(
self, package_level, message=None, confirmation_required=False
):
data = self._data_after_package_scanned(package_level)
data["confirmation_required"] = confirmation_required
- return self._response(next_state="scan_location", data=data, message=message,)
+ return self._response(
+ next_state="scan_location",
+ data=data,
+ message=message,
+ )
def _response_for_show_completion_info(self, message=None):
return self._response(next_state="show_completion_info", message=message)
diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py
index a18adba265..6a9d4e74a7 100644
--- a/shopfloor/tests/test_checkout_cancel_line.py
+++ b/shopfloor/tests/test_checkout_cancel_line.py
@@ -103,11 +103,13 @@ def test_cancel_line_ok(self):
# and now, we want to drop the new_package
response = self.service.dispatch(
- "cancel_line", params={"picking_id": picking.id, "line_id": raw_line.id},
+ "cancel_line",
+ params={"picking_id": picking.id, "line_id": raw_line.id},
)
self.assertRecordValues(
- raw_line, [{"qty_done": 0, "shopfloor_checkout_done": False}],
+ raw_line,
+ [{"qty_done": 0, "shopfloor_checkout_done": False}],
)
self.assert_response(
diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py
index e60a67ecfc..07cb846c34 100644
--- a/shopfloor/tests/test_checkout_no_package.py
+++ b/shopfloor/tests/test_checkout_no_package.py
@@ -42,10 +42,12 @@ def test_no_package_ok(self):
)
self.assertRecordValues(
- move_line1, [{"result_package_id": False, "shopfloor_checkout_done": True}],
+ move_line1,
+ [{"result_package_id": False, "shopfloor_checkout_done": True}],
)
self.assertRecordValues(
- move_line2, [{"result_package_id": False, "shopfloor_checkout_done": True}],
+ move_line2,
+ [{"result_package_id": False, "shopfloor_checkout_done": True}],
)
self.assertRecordValues(
move_line3,
diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py
index e1d7dc983d..02f35414af 100644
--- a/shopfloor/tests/test_cluster_picking_select.py
+++ b/shopfloor/tests/test_cluster_picking_select.py
@@ -58,7 +58,9 @@ def test_find_batch_in_progress_current_user(self):
# user and in progress (first priority)
data = self.data.picking_batch(self.batch3, with_pickings=True)
self.assert_response(
- response, next_state="confirm_start", data=data,
+ response,
+ next_state="confirm_start",
+ data=data,
)
def test_find_batch_assigned(self):
@@ -78,7 +80,9 @@ def test_find_batch_assigned(self):
# we expect to find batch 2 as it's assigned to the current user
data = self.data.picking_batch(self.batch2, with_pickings=True)
self.assert_response(
- response, next_state="confirm_start", data=data,
+ response,
+ next_state="confirm_start",
+ data=data,
)
def test_find_batch_unassigned_draft(self):
@@ -97,7 +101,9 @@ def test_find_batch_unassigned_draft(self):
# available
data = self.data.picking_batch(self.batch2, with_pickings=True)
self.assert_response(
- response, next_state="confirm_start", data=data,
+ response,
+ next_state="confirm_start",
+ data=data,
)
def test_find_batch_not_found(self):
@@ -153,7 +159,9 @@ def test_select_in_progress_assigned(self):
# we don't care in these tests, 'find_batch' tests them already
data["pickings"] = self.ANY
self.assert_response(
- response, next_state="confirm_start", data=data,
+ response,
+ next_state="confirm_start",
+ data=data,
)
def test_select_draft_assigned(self):
@@ -171,7 +179,9 @@ def test_select_draft_assigned(self):
# we don't care in these tests, 'find_batch' tests them already
data["pickings"] = self.ANY
self.assert_response(
- response, next_state="confirm_start", data=data,
+ response,
+ next_state="confirm_start",
+ data=data,
)
def test_select_draft_unassigned(self):
@@ -188,7 +198,9 @@ def test_select_draft_unassigned(self):
# we don't care in these tests, 'find_batch' tests them already
data["pickings"] = self.ANY
self.assert_response(
- response, next_state="confirm_start", data=data,
+ response,
+ next_state="confirm_start",
+ data=data,
)
def test_select_not_exists(self):
@@ -323,7 +335,9 @@ def test_confirm_start_ok(self):
data["picking"] = self.data.picking(picking)
data["batch"] = self.data.picking_batch(batch)
self.assert_response(
- response, data=data, next_state="start_line",
+ response,
+ data=data,
+ next_state="start_line",
)
def test_confirm_start_not_exists(self):
diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py
index bb28881c70..03feec18fd 100644
--- a/shopfloor/tests/test_cluster_picking_skip.py
+++ b/shopfloor/tests/test_cluster_picking_skip.py
@@ -5,8 +5,7 @@
class ClusterPickingSkipLineCase(ClusterPickingCommonCase):
- """Tests covering the /skip_line endpoint
- """
+ """Tests covering the /skip_line endpoint"""
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py
index eb878075f5..ce486d3ee6 100644
--- a/shopfloor/tests/test_cluster_picking_stock_issue.py
+++ b/shopfloor/tests/test_cluster_picking_stock_issue.py
@@ -5,8 +5,7 @@
class ClusterPickingStockIssue(ClusterPickingCommonCase):
- """Tests covering the /stock_issue endpoint
- """
+ """Tests covering the /stock_issue endpoint"""
@classmethod
def setUpClassBaseData(cls, *args, **kwargs):
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index 5d9ed6f0bb..b32dcf1938 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -93,7 +93,9 @@ def test_prepare_unload_all_same_dest(self):
location = self.packing_location
data = self._data_for_batch(self.batch, location)
self.assert_response(
- response, next_state="unload_all", data=data,
+ response,
+ next_state="unload_all",
+ data=data,
)
def test_prepare_unload_different_dest(self):
@@ -110,7 +112,9 @@ def test_prepare_unload_different_dest(self):
location = first_line.location_dest_id
data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
- response, next_state="unload_single", data=data,
+ response,
+ next_state="unload_single",
+ data=data,
)
@@ -252,7 +256,9 @@ def test_set_destination_all_but_different_dest(self):
location = move_lines[0].location_dest_id
data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
- response, next_state="unload_single", data=data,
+ response,
+ next_state="unload_single",
+ data=data,
)
def test_set_destination_all_error_location_not_found(self):
@@ -346,7 +352,9 @@ def test_set_destination_all_need_confirmation(self):
location = move_lines[0].location_dest_id
data = self._data_for_batch(self.batch, location)
self.assert_response(
- response, next_state="confirm_unload_all", data=data,
+ response,
+ next_state="confirm_unload_all",
+ data=data,
)
def test_set_destination_all_with_confirmation(self):
@@ -439,7 +447,9 @@ def test_unload_scan_pack_ok(self):
location = self.move_lines[0].location_dest_id
data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
- response, next_state="unload_set_destination", data=data,
+ response,
+ next_state="unload_set_destination",
+ data=data,
)
def test_unload_scan_pack_wrong_barcode(self):
@@ -543,7 +553,9 @@ def test_unload_scan_destination_ok(self):
location = self.bin2_lines[0].location_dest_id
data = self._data_for_batch(self.batch, location, pack=self.bin2)
self.assert_response(
- response, next_state="unload_single", data=data,
+ response,
+ next_state="unload_single",
+ data=data,
)
def test_unload_scan_destination_one_line_of_picking_only(self):
@@ -603,7 +615,9 @@ def test_unload_scan_destination_one_line_of_picking_only(self):
location = bin3_line.location_dest_id
data = self._data_for_batch(self.batch, location, pack=bin3)
self.assert_response(
- response, next_state="unload_single", data=data,
+ response,
+ next_state="unload_single",
+ data=data,
)
def test_unload_scan_destination_last_line(self):
@@ -735,7 +749,9 @@ def test_unload_scan_destination_need_confirmation(self):
location = self.bin1_lines[0].location_dest_id
data = self._data_for_batch(self.batch, location, pack=self.bin1)
self.assert_response(
- response, next_state="confirm_unload_set_destination", data=data,
+ response,
+ next_state="confirm_unload_set_destination",
+ data=data,
)
def test_unload_scan_destination_with_confirmation(self):
diff --git a/shopfloor/tests/test_delivery_done.py b/shopfloor/tests/test_delivery_done.py
index 8c49038db3..1162aa1eb8 100644
--- a/shopfloor/tests/test_delivery_done.py
+++ b/shopfloor/tests/test_delivery_done.py
@@ -46,7 +46,8 @@ def test_done_all_qty_done(self):
move_line.qty_done = move_line.product_uom_qty
response = self.service.dispatch("done", params={"picking_id": self.picking.id})
self.assert_response_deliver(
- response, message=self.service.msg_store.transfer_complete(self.picking),
+ response,
+ message=self.service.msg_store.transfer_complete(self.picking),
)
self.assertEqual(self.picking.state, "done")
@@ -79,7 +80,8 @@ def test_done_no_qty_done_confirm(self):
"done", params={"picking_id": self.picking.id, "confirm": True}
)
self.assert_response_deliver(
- response, message=self.service.msg_store.transfer_no_qty_done(),
+ response,
+ message=self.service.msg_store.transfer_no_qty_done(),
)
self.assertEqual(self.picking.state, "assigned")
@@ -94,7 +96,8 @@ def test_done_some_qty_done_confirm(self):
"done", params={"picking_id": self.picking.id, "confirm": True}
)
self.assert_response_deliver(
- response, message=self.service.msg_store.transfer_complete(self.picking),
+ response,
+ message=self.service.msg_store.transfer_complete(self.picking),
)
self.assertEqual(self.picking.state, "done")
self.assertEqual(self.picking.move_lines, self.raw_move)
diff --git a/shopfloor/tests/test_delivery_list_stock_picking.py b/shopfloor/tests/test_delivery_list_stock_picking.py
index 056557d407..ec82a350a5 100644
--- a/shopfloor/tests/test_delivery_list_stock_picking.py
+++ b/shopfloor/tests/test_delivery_list_stock_picking.py
@@ -21,7 +21,8 @@ def test_list_stock_picking_ko(self):
"""No picking is ready, no picking to list."""
response = self.service.dispatch("list_stock_picking", params={})
self.assert_response_manual_selection(
- response, pickings=[],
+ response,
+ pickings=[],
)
def test_list_stock_picking_ok(self):
@@ -32,7 +33,8 @@ def test_list_stock_picking_ok(self):
response = self.service.dispatch("list_stock_picking", params={})
# picking1 only available
self.assert_response_manual_selection(
- response, pickings=self.picking1,
+ response,
+ pickings=self.picking1,
)
# prepare 2nd picking
self._fill_stock_for_moves(self.picking2.move_lines)
@@ -40,5 +42,6 @@ def test_list_stock_picking_ok(self):
response = self.service.dispatch("list_stock_picking", params={})
# all pickings available
self.assert_response_manual_selection(
- response, pickings=self.picking1 + self.picking2,
+ response,
+ pickings=self.picking1 + self.picking2,
)
diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py
index c1aa38a00a..ae2cd598f6 100644
--- a/shopfloor/tests/test_delivery_scan_deliver.py
+++ b/shopfloor/tests/test_delivery_scan_deliver.py
@@ -81,7 +81,8 @@ def test_scan_deliver_error_barcode_not_found(self):
"scan_deliver", params={"barcode": "NO VALID BARCODE", "picking_id": None}
)
self.assert_response_deliver(
- response, message=self.service.msg_store.barcode_not_found(),
+ response,
+ message=self.service.msg_store.barcode_not_found(),
)
def test_scan_deliver_error_barcode_not_found_keep_picking(self):
@@ -168,7 +169,8 @@ def test_scan_deliver_scan_product_not_found(self):
"scan_deliver", params={"barcode": self.free_product.barcode}
)
self.assert_response_deliver(
- response, message=self.service.msg_store.product_not_found_in_pickings(),
+ response,
+ message=self.service.msg_store.product_not_found_in_pickings(),
)
def test_scan_deliver_scan_lot_ok(self):
@@ -179,7 +181,8 @@ def test_scan_deliver_scan_lot_ok(self):
def test_scan_deliver_scan_lot_not_found(self):
response = self.service.dispatch("scan_deliver", params={"barcode": "FREE_LOT"})
self.assert_response_deliver(
- response, message=self.service.msg_store.lot_not_found_in_pickings(),
+ response,
+ message=self.service.msg_store.lot_not_found_in_pickings(),
)
def test_scan_deliver_scan_lot_in_mixed_package(self):
@@ -217,13 +220,15 @@ def test_scan_deliver_picking_done(self):
self.assertEqual(self.picking.state, "assigned")
lot = self.raw_lot_move.move_line_ids.lot_id
response = self.service.dispatch(
- "scan_deliver", params={"barcode": lot.name, "picking_id": self.picking.id},
+ "scan_deliver",
+ params={"barcode": lot.name, "picking_id": self.picking.id},
)
self.assertEqual(self.picking.state, "assigned")
packages_f = self.pack3_move.move_line_ids.mapped("package_id")
# While all lines are not processed, response still returns the picking
self.assert_response_deliver(
- response, picking=self.picking,
+ response,
+ picking=self.picking,
)
response = None
# Once all lines are processed, the last response has no picking returned
@@ -234,7 +239,8 @@ def test_scan_deliver_picking_done(self):
)
self.assertEqual(self.picking.state, "done")
self.assert_response_deliver(
- response, message=self.service.msg_store.transfer_complete(self.picking),
+ response,
+ message=self.service.msg_store.transfer_complete(self.picking),
)
diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py
index f4076b1bfe..cbc1c2c852 100644
--- a/shopfloor/tests/test_location_content_transfer_base.py
+++ b/shopfloor/tests/test_location_content_transfer_base.py
@@ -105,7 +105,10 @@ def _assert_response_scan_destination(
data = self.service._data_content_line_for_location(location, next_content)
data["confirmation_required"] = confirmation_required
self.assert_response(
- response, next_state=state, data=data, message=message,
+ response,
+ next_state=state,
+ data=data,
+ message=message,
)
def assert_response_scan_destination(
diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py
index 2b440f5202..b7f6753cb5 100644
--- a/shopfloor/tests/test_location_content_transfer_mix.py
+++ b/shopfloor/tests/test_location_content_transfer_mix.py
@@ -158,7 +158,8 @@ def _location_content_transfer_process_line(
self.assert_response_start(
response,
message=service.msg_store.location_content_transfer_complete(
- pack_location, out_location,
+ pack_location,
+ out_location,
),
)
self.assertEqual(move_line.state, "done")
@@ -339,7 +340,8 @@ def test_with_zone_picking2(self):
self.assert_response_start(
response,
message=self.service.msg_store.location_content_transfer_complete(
- pack_first_pallet.location_id, pack_first_pallet.location_dest_id,
+ pack_first_pallet.location_id,
+ pack_first_pallet.location_dest_id,
),
)
self.assertEqual(pack_first_pallet.qty_done, 4)
diff --git a/shopfloor/tests/test_location_content_transfer_putaway.py b/shopfloor/tests/test_location_content_transfer_putaway.py
index 5b82a61f41..6421f71bab 100644
--- a/shopfloor/tests/test_location_content_transfer_putaway.py
+++ b/shopfloor/tests/test_location_content_transfer_putaway.py
@@ -5,8 +5,7 @@
class TestLocationContentTransferPutaway(LocationContentTransferCommonCase):
- """Tests with putaway when using option to ignore unavailable putaway locations
- """
+ """Tests with putaway when using option to ignore unavailable putaway locations"""
@classmethod
def setUpClassVars(cls, *args, **kwargs):
@@ -70,7 +69,9 @@ def test_normal_putaway(self):
"scan_location", params={"barcode": self.test_loc.barcode}
)
self.assert_response(
- response, next_state="start_single", data=self.ANY,
+ response,
+ next_state="start_single",
+ data=self.ANY,
)
package_level_id = response["data"]["start_single"]["package_level"]["id"]
package_level = self.env["stock.package_level"].browse(package_level_id)
diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
index 6212307b49..1853edba35 100644
--- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
+++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py
@@ -77,7 +77,8 @@ def test_set_destination_package_wrong_parameters(self):
)
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
- response, move_lines.mapped("picking_id"),
+ response,
+ move_lines.mapped("picking_id"),
)
def test_set_destination_package_dest_location_nok(self):
@@ -93,7 +94,9 @@ def test_set_destination_package_dest_location_nok(self):
},
)
self.assert_response_scan_destination(
- response, package_level, message=self.service.msg_store.no_location_found(),
+ response,
+ package_level,
+ message=self.service.msg_store.no_location_found(),
)
# Destination location not allowed
customer_location = self.env.ref("stock.stock_location_customers")
@@ -255,7 +258,8 @@ def test_set_destination_line_wrong_parameters(self):
)
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
- response, move_lines.mapped("picking_id"),
+ response,
+ move_lines.mapped("picking_id"),
)
def test_set_destination_line_dest_location_nok(self):
@@ -272,7 +276,9 @@ def test_set_destination_line_dest_location_nok(self):
},
)
self.assert_response_scan_destination(
- response, move_line, message=self.service.msg_store.no_location_found(),
+ response,
+ move_line,
+ message=self.service.msg_store.no_location_found(),
)
# Destination location not allowed
customer_location = self.env.ref("stock.stock_location_customers")
@@ -552,7 +558,9 @@ def setUpClassBaseData(cls):
)
cls.move_product_a.product_uom_qty = 15
cls._update_qty_in_location(
- cls.picking.location_id, cls.product_a, 5,
+ cls.picking.location_id,
+ cls.product_a,
+ 5,
)
# Put product_b quantities in two different source locations to get
# two stock move lines (6 and 4 to satisfy 10 qties)
diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py
index 1a4c2daeca..ae416df886 100644
--- a/shopfloor/tests/test_location_content_transfer_single.py
+++ b/shopfloor/tests/test_location_content_transfer_single.py
@@ -321,7 +321,8 @@ def test_postpone_package_wrong_parameters(self):
)
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
- response, move_lines.mapped("picking_id"),
+ response,
+ move_lines.mapped("picking_id"),
)
def test_postpone_package_ok(self):
@@ -337,7 +338,8 @@ def test_postpone_package_ok(self):
self.assertTrue(package_level.shopfloor_postponed)
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
- response, move_lines.mapped("picking_id"),
+ response,
+ move_lines.mapped("picking_id"),
)
def test_postpone_sorter(self):
@@ -379,7 +381,8 @@ def test_postpone_line_wrong_parameters(self):
)
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
- response, move_lines.mapped("picking_id"),
+ response,
+ move_lines.mapped("picking_id"),
)
def test_postpone_line_ok(self):
@@ -392,7 +395,8 @@ def test_postpone_line_ok(self):
self.assertTrue(move_line.shopfloor_postponed)
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
- response, move_lines.mapped("picking_id"),
+ response,
+ move_lines.mapped("picking_id"),
)
def test_stock_out_package_wrong_parameters(self):
@@ -419,7 +423,8 @@ def test_stock_out_package_wrong_parameters(self):
)
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
- response, move_lines.mapped("picking_id"),
+ response,
+ move_lines.mapped("picking_id"),
)
def test_stock_out_package_ok(self):
@@ -434,7 +439,8 @@ def test_stock_out_package_ok(self):
)
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
- response, move_lines.mapped("picking_id"),
+ response,
+ move_lines.mapped("picking_id"),
)
def test_stock_out_line_wrong_parameters(self):
@@ -461,7 +467,8 @@ def test_stock_out_line_wrong_parameters(self):
)
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
- response, move_lines.mapped("picking_id"),
+ response,
+ move_lines.mapped("picking_id"),
)
def test_dismiss_package_level_ok(self):
@@ -507,7 +514,8 @@ def test_dismiss_package_level_error_no_location(self):
params={"location_id": 0, "package_level_id": package_level.id},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
@@ -550,7 +558,9 @@ def setUpClassBaseData(cls):
)
cls.move_product_a.product_uom_qty = 15
cls._update_qty_in_location(
- cls.content_loc, cls.product_a, 5,
+ cls.content_loc,
+ cls.product_a,
+ 5,
)
# Put product_b quantities in two different source locations to get
# two stock move lines (6 and 4 to satisfy 10 qties)
@@ -611,7 +621,8 @@ def test_stock_out_package_split_move(self):
# Check the response
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
- response, move_lines.mapped("picking_id"),
+ response,
+ move_lines.mapped("picking_id"),
)
def test_stock_out_line_split_move(self):
@@ -667,5 +678,6 @@ def test_stock_out_line_split_move(self):
# Check the response
move_lines = self.service._find_transfer_move_lines(self.content_loc)
self.assert_response_start_single(
- response, move_lines.mapped("picking_id"),
+ response,
+ move_lines.mapped("picking_id"),
)
diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py
index c193fd0316..ebdfb1c7bf 100644
--- a/shopfloor/tests/test_location_content_transfer_start.py
+++ b/shopfloor/tests/test_location_content_transfer_start.py
@@ -211,7 +211,8 @@ def test_scan_location_wrong_picking_type_allow_unreserve_ok(self):
self.assert_response_scan_destination_all(response, new_picking)
self.assertRecordValues(new_picking, [{"user_id": self.env.uid}])
self.assertRecordValues(
- new_picking.move_line_ids, [{"qty_done": 10.0}, {"qty_done": 10.0}],
+ new_picking.move_line_ids,
+ [{"qty_done": 10.0}, {"qty_done": 10.0}],
)
self.assertRecordValues(new_picking.package_level_ids, [{"is_done": True}])
@@ -282,7 +283,9 @@ def test_scan_location_create_moves(self):
picking_type = self.menu.picking_type_ids
# product_a alone
self.env["stock.quant"]._update_available_quantity(
- self.product_a, self.content_loc, 10,
+ self.product_a,
+ self.content_loc,
+ 10,
)
# product_b in a package
package = self.env["stock.quant.package"].create({})
diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py
index e1d20ac132..3e49d472ed 100644
--- a/shopfloor/tests/test_scan_anything.py
+++ b/shopfloor/tests/test_scan_anything.py
@@ -14,7 +14,8 @@ def _test_response_ok(self, rec_type, data, identifier):
params = {"identifier": identifier}
response = self.service.dispatch("scan", params=params)
self.assert_response(
- response, data={"type": rec_type, "identifier": identifier, "record": data},
+ response,
+ data={"type": rec_type, "identifier": identifier, "record": data},
)
def _test_response_ko(self, identifier, tried=None):
diff --git a/shopfloor/tests/test_single_pack_transfer_putaway.py b/shopfloor/tests/test_single_pack_transfer_putaway.py
index aeb953a498..586fb145a9 100644
--- a/shopfloor/tests/test_single_pack_transfer_putaway.py
+++ b/shopfloor/tests/test_single_pack_transfer_putaway.py
@@ -42,7 +42,9 @@ def test_normal_putaway(self):
"start", params={"barcode": self.shelf1.barcode}
)
self.assert_response(
- response, next_state="scan_location", data=self.ANY,
+ response,
+ next_state="scan_location",
+ data=self.ANY,
)
package_level_id = response["data"]["scan_location"]["id"]
package_level = self.env["stock.package_level"].browse(package_level_id)
diff --git a/shopfloor/tests/test_user.py b/shopfloor/tests/test_user.py
index e1175ae8bb..fa873cb42c 100644
--- a/shopfloor/tests/test_user.py
+++ b/shopfloor/tests/test_user.py
@@ -35,5 +35,6 @@ def test_menu_by_profile(self):
response = self.service.dispatch("menu")
self.assert_response(
- response, data={"menus": [self._data_for_menu_item(menu)]},
+ response,
+ data={"menus": [self._data_for_menu_item(menu)]},
)
diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py
index 2f7661ba24..69dbc125ba 100644
--- a/shopfloor/tests/test_zone_picking_base.py
+++ b/shopfloor/tests/test_zone_picking_base.py
@@ -255,7 +255,10 @@ def setUp(self):
def _assert_response_select_zone(self, response, zone_locations, message=None):
data = {"zones": self.service._data_for_select_zone(zone_locations)}
self.assert_response(
- response, next_state="start", data=data, message=message,
+ response,
+ next_state="start",
+ data=data,
+ message=message,
)
def assert_response_start(self, response, zone_locations=None, message=None):
@@ -268,7 +271,10 @@ def _assert_response_select_picking_type(
):
data = self.service._data_for_select_picking_type(zone_location, picking_types)
self.assert_response(
- response, next_state=state, data=data, message=message,
+ response,
+ next_state=state,
+ data=data,
+ message=message,
)
def assert_response_select_picking_type(
@@ -303,7 +309,11 @@ def _assert_response_select_line(
"location_will_be_empty"
] = move_line.location_id.planned_qty_in_location_is_empty(move_line)
self.assert_response(
- response, next_state=state, data=data, message=message, popup=popup,
+ response,
+ next_state=state,
+ data=data,
+ message=message,
+ popup=popup,
)
def assert_response_select_line(
@@ -367,7 +377,13 @@ def assert_response_set_line_destination(
)
def _assert_response_zero_check(
- self, state, response, zone_location, picking_type, location, message=None,
+ self,
+ state,
+ response,
+ zone_location,
+ picking_type,
+ location,
+ message=None,
):
self.assert_response(
response,
@@ -381,7 +397,12 @@ def _assert_response_zero_check(
)
def assert_response_zero_check(
- self, response, zone_location, picking_type, location, message=None,
+ self,
+ response,
+ zone_location,
+ picking_type,
+ location,
+ message=None,
):
self._assert_response_zero_check(
"zero_check",
@@ -393,7 +414,13 @@ def assert_response_zero_check(
)
def _assert_response_change_pack_lot(
- self, state, response, zone_location, picking_type, move_line, message=None,
+ self,
+ state,
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=None,
):
self.assert_response(
response,
@@ -407,7 +434,12 @@ def _assert_response_change_pack_lot(
)
def assert_response_change_pack_lot(
- self, response, zone_location, picking_type, move_line, message=None,
+ self,
+ response,
+ zone_location,
+ picking_type,
+ move_line,
+ message=None,
):
self._assert_response_change_pack_lot(
"change_pack_lot",
diff --git a/shopfloor/tests/test_zone_picking_change_pack_lot.py b/shopfloor/tests/test_zone_picking_change_pack_lot.py
index bfcca57407..51985e041a 100644
--- a/shopfloor/tests/test_zone_picking_change_pack_lot.py
+++ b/shopfloor/tests/test_zone_picking_change_pack_lot.py
@@ -26,7 +26,8 @@ def test_change_pack_lot_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"change_pack_lot",
@@ -38,7 +39,8 @@ def test_change_pack_lot_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"change_pack_lot",
@@ -50,7 +52,8 @@ def test_change_pack_lot_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
def test_change_pack_lot_no_package_or_lot_for_barcode(self):
diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py
index 7c5eadeddd..35a0ab8e73 100644
--- a/shopfloor/tests/test_zone_picking_select_line.py
+++ b/shopfloor/tests/test_zone_picking_select_line.py
@@ -85,7 +85,10 @@ def test_list_move_lines_order_by_location(self):
zone_location, picking_type, order="location"
)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
)
def test_list_move_lines_order_by_priority(self):
@@ -103,7 +106,10 @@ def test_list_move_lines_order_by_priority(self):
zone_location, picking_type, order="priority"
)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
)
def test_scan_source_wrong_parameters(self):
@@ -118,7 +124,8 @@ def test_scan_source_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"scan_source",
@@ -129,7 +136,8 @@ def test_scan_source_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
def test_scan_source_barcode_location_not_allowed(self):
@@ -145,7 +153,8 @@ def test_scan_source_barcode_location_not_allowed(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.location_not_allowed(),
+ response,
+ message=self.service.msg_store.location_not_allowed(),
)
def test_scan_source_barcode_location_one_move_line(self):
@@ -259,7 +268,9 @@ def test_scan_source_barcode_package(self):
},
)
move_lines = self.service._find_location_move_lines(
- zone_location, picking_type, package=package,
+ zone_location,
+ picking_type,
+ package=package,
)
move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
move_line = move_lines[0]
@@ -309,7 +320,9 @@ def test_scan_source_barcode_product(self):
},
)
move_line = self.service._find_location_move_lines(
- zone_location, picking_type, product=self.product_a,
+ zone_location,
+ picking_type,
+ product=self.product_a,
)
self.assert_response_set_line_destination(
response,
@@ -358,7 +371,9 @@ def test_scan_source_barcode_lot(self):
},
)
move_lines = self.service._find_location_move_lines(
- zone_location, picking_type, lot=lot,
+ zone_location,
+ picking_type,
+ lot=lot,
)
move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True)
move_line = move_lines[0]
@@ -424,7 +439,9 @@ def test_scan_source_multi_users(self):
picking_type = self.picking1.picking_type_id
# - scan source
response = self.service.scan_source(
- zone_location.id, picking_type.id, self.zone_sublocation1.barcode,
+ zone_location.id,
+ picking_type.id,
+ self.zone_sublocation1.barcode,
)
move_line = self.picking1.move_line_ids
self.assertEqual(response["next_state"], "set_line_destination")
@@ -444,7 +461,9 @@ def test_scan_source_multi_users(self):
) as work:
service = work.component(usage="zone_picking")
response = service.scan_source(
- zone_location.id, picking_type.id, self.zone_sublocation1.barcode,
+ zone_location.id,
+ picking_type.id,
+ self.zone_sublocation1.barcode,
)
self.assertEqual(response["next_state"], "select_line")
self.assertEqual(
@@ -463,7 +482,8 @@ def test_prepare_unload_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"prepare_unload",
@@ -473,7 +493,8 @@ def test_prepare_unload_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
def test_prepare_unload_buffer_empty(self):
@@ -490,7 +511,10 @@ def test_prepare_unload_buffer_empty(self):
# check response
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
)
def test_prepare_unload_buffer_one_line(self):
@@ -518,7 +542,10 @@ def test_prepare_unload_buffer_one_line(self):
)
# check response
self.assert_response_unload_set_destination(
- response, zone_location, picking_type, move_line,
+ response,
+ zone_location,
+ picking_type,
+ move_line,
)
def test_prepare_unload_buffer_multi_line_same_destination(self):
@@ -555,7 +582,10 @@ def test_prepare_unload_buffer_multi_line_same_destination(self):
)
# check response
self.assert_response_unload_all(
- response, zone_location, picking_type, self.picking5.move_line_ids,
+ response,
+ zone_location,
+ picking_type,
+ self.picking5.move_line_ids,
)
def test_list_move_lines_empty_location(self):
@@ -573,7 +603,10 @@ def test_list_move_lines_empty_location(self):
zone_location, picking_type, order="location"
)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
)
data_move_lines = response["data"]["select_line"]["move_lines"]
# Check that the move line in "Zone sub-location 1" is about to empty
diff --git a/shopfloor/tests/test_zone_picking_select_picking_type.py b/shopfloor/tests/test_zone_picking_select_picking_type.py
index 76b999d929..7638292fa2 100644
--- a/shopfloor/tests/test_zone_picking_select_picking_type.py
+++ b/shopfloor/tests/test_zone_picking_select_picking_type.py
@@ -21,7 +21,8 @@ def test_list_move_lines_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"list_move_lines",
@@ -31,7 +32,8 @@ def test_list_move_lines_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
def test_list_move_lines_ok(self):
diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py
index 3099b76eb8..a4c1594b92 100644
--- a/shopfloor/tests/test_zone_picking_set_line_destination.py
+++ b/shopfloor/tests/test_zone_picking_set_line_destination.py
@@ -26,7 +26,8 @@ def test_set_destination_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"set_destination",
@@ -40,7 +41,8 @@ def test_set_destination_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"set_destination",
@@ -54,7 +56,8 @@ def test_set_destination_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
def test_set_destination_location_confirm(self):
@@ -388,7 +391,10 @@ def test_set_destination_location_zero_check(self):
self.assertTrue(location_is_empty())
# Check response
self.assert_response_zero_check(
- response, zone_location, picking_type, move_line.location_id,
+ response,
+ zone_location,
+ picking_type,
+ move_line.location_id,
)
def test_set_destination_package_full_qty(self):
@@ -544,5 +550,8 @@ def test_set_destination_package_zero_check(self):
self.assertTrue(location_is_empty())
# Check response
self.assert_response_zero_check(
- response, zone_location, picking_type, move_line.location_id,
+ response,
+ zone_location,
+ picking_type,
+ move_line.location_id,
)
diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py
index 6f20c4eb95..6bda88beaa 100644
--- a/shopfloor/tests/test_zone_picking_start.py
+++ b/shopfloor/tests/test_zone_picking_start.py
@@ -24,7 +24,8 @@ def setUpClassBaseData(cls, *args, **kwargs):
}
)
cls.extra_picking = extra_picking = cls._create_picking(
- lines=[(cls.product_b, 10)], picking_type=bad_picking_type,
+ lines=[(cls.product_b, 10)],
+ picking_type=bad_picking_type,
)
cls._fill_stock_for_moves(
extra_picking.move_lines, in_package=True, location=cls.zone_sublocation1
@@ -128,10 +129,12 @@ def test_select_zone(self):
def test_scan_location_wrong_barcode(self):
"""Scanned location invalid, no location found."""
response = self.service.dispatch(
- "scan_location", params={"barcode": "UNKNOWN LOCATION"},
+ "scan_location",
+ params={"barcode": "UNKNOWN LOCATION"},
)
self.assert_response_start(
- response, message=self.service.msg_store.no_location_found(),
+ response,
+ message=self.service.msg_store.no_location_found(),
)
def test_scan_location_not_allowed(self):
@@ -139,10 +142,12 @@ def test_scan_location_not_allowed(self):
types' source location.
"""
response = self.service.dispatch(
- "scan_location", params={"barcode": self.customer_location.barcode},
+ "scan_location",
+ params={"barcode": self.customer_location.barcode},
)
self.assert_response_start(
- response, message=self.service.msg_store.location_not_allowed(),
+ response,
+ message=self.service.msg_store.location_not_allowed(),
)
def test_scan_location_no_move_lines(self):
@@ -151,17 +156,22 @@ def test_scan_location_no_move_lines(self):
# no more lines available
sub1_lines.picking_id.action_cancel()
response = self.service.dispatch(
- "scan_location", params={"barcode": self.zone_sublocation1.barcode},
+ "scan_location",
+ params={"barcode": self.zone_sublocation1.barcode},
)
self.assert_response_start(
- response, message=self.service.msg_store.no_lines_to_process(),
+ response,
+ message=self.service.msg_store.no_lines_to_process(),
)
def test_scan_location_ok(self):
"""Scanned location valid, list of picking types of related move lines."""
response = self.service.dispatch(
- "scan_location", params={"barcode": self.zone_location.barcode},
+ "scan_location",
+ params={"barcode": self.zone_location.barcode},
)
self.assert_response_select_picking_type(
- response, zone_location=self.zone_location, picking_types=self.picking_type,
+ response,
+ zone_location=self.zone_location,
+ picking_types=self.picking_type,
)
diff --git a/shopfloor/tests/test_zone_picking_stock_issue.py b/shopfloor/tests/test_zone_picking_stock_issue.py
index 88e66e8f2d..d755113e70 100644
--- a/shopfloor/tests/test_zone_picking_stock_issue.py
+++ b/shopfloor/tests/test_zone_picking_stock_issue.py
@@ -23,7 +23,8 @@ def test_stock_issue_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"stock_issue",
@@ -34,7 +35,8 @@ def test_stock_issue_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"stock_issue",
@@ -45,7 +47,8 @@ def test_stock_issue_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
def test_stock_issue_no_more_reservation(self):
@@ -65,7 +68,10 @@ def test_stock_issue_no_more_reservation(self):
self.assertFalse(move.move_line_ids)
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
)
def test_stock_issue1(self):
@@ -87,7 +93,10 @@ def test_stock_issue1(self):
self.assertFalse(move.move_line_ids)
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
)
# Check that the inventory exists
inventory = self.env["stock.inventory"].search(
@@ -129,7 +138,10 @@ def test_stock_issue2(self):
self.assertTrue(move.move_line_ids)
self.assertEqual(move.move_line_ids.location_id, location)
self.assert_response_set_line_destination(
- response, zone_location, picking_type, move.move_line_ids,
+ response,
+ zone_location,
+ picking_type,
+ move.move_line_ids,
)
# Check the inventory
inventory = self.env["stock.inventory"].search(
@@ -173,7 +185,10 @@ def test_stock_issue3(self):
self.assertTrue(move.move_line_ids)
self.assertEqual(move.move_line_ids.location_id, self.zone_sublocation2)
self.assert_response_set_line_destination(
- response, zone_location, picking_type, move.move_line_ids,
+ response,
+ zone_location,
+ picking_type,
+ move.move_line_ids,
)
# Check the inventory
inventory = self.env["stock.inventory"].search(
diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py
index 23ec7800e8..33997cf93d 100644
--- a/shopfloor/tests/test_zone_picking_unload_all.py
+++ b/shopfloor/tests/test_zone_picking_unload_all.py
@@ -23,7 +23,8 @@ def test_set_destination_all_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"set_destination_all",
@@ -34,7 +35,8 @@ def test_set_destination_all_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
def test_set_destination_all_different_destination(self):
@@ -147,7 +149,8 @@ def test_set_destination_all_confirm_destination(self):
picking_type,
buffer_lines,
message=self.service.msg_store.confirm_location_changed(
- packing_sublocation1, packing_sublocation2,
+ packing_sublocation1,
+ packing_sublocation2,
),
confirmation_required=True,
)
@@ -389,7 +392,10 @@ def test_unload_split_buffer_one_line(self):
# check response
buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
self.assert_response_unload_set_destination(
- response, zone_location, picking_type, buffer_lines,
+ response,
+ zone_location,
+ picking_type,
+ buffer_lines,
)
def test_unload_split_buffer_multi_lines(self):
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
index 6108454386..5e6b935032 100644
--- a/shopfloor/tests/test_zone_picking_unload_set_destination.py
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -44,7 +44,8 @@ def test_unload_set_destination_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"unload_set_destination",
@@ -56,7 +57,8 @@ def test_unload_set_destination_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"unload_set_destination",
diff --git a/shopfloor/tests/test_zone_picking_unload_single.py b/shopfloor/tests/test_zone_picking_unload_single.py
index 03f72b41a4..1db582c00c 100644
--- a/shopfloor/tests/test_zone_picking_unload_single.py
+++ b/shopfloor/tests/test_zone_picking_unload_single.py
@@ -25,7 +25,8 @@ def test_unload_scan_pack_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"unload_scan_pack",
@@ -37,7 +38,8 @@ def test_unload_scan_pack_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
# wrong package ID, and there is still a move line to unload
# => get back on 'unload_single' screen
@@ -128,7 +130,10 @@ def test_unload_scan_pack_barcode_match(self):
},
)
self.assert_response_unload_set_destination(
- response, zone_location, picking_type, move_line,
+ response,
+ zone_location,
+ picking_type,
+ move_line,
)
def test_unload_scan_pack_barcode_not_match(self):
diff --git a/shopfloor/tests/test_zone_picking_zero_check.py b/shopfloor/tests/test_zone_picking_zero_check.py
index 49738d8366..48ad12ab06 100644
--- a/shopfloor/tests/test_zone_picking_zero_check.py
+++ b/shopfloor/tests/test_zone_picking_zero_check.py
@@ -24,7 +24,8 @@ def test_is_zero_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"is_zero",
@@ -36,7 +37,8 @@ def test_is_zero_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
response = self.service.dispatch(
"is_zero",
@@ -48,7 +50,8 @@ def test_is_zero_wrong_parameters(self):
},
)
self.assert_response_start(
- response, message=self.service.msg_store.record_not_found(),
+ response,
+ message=self.service.msg_store.record_not_found(),
)
def test_is_zero_is_empty(self):
@@ -67,7 +70,10 @@ def test_is_zero_is_empty(self):
)
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
)
def test_is_zero_is_not_empty(self):
@@ -86,7 +92,10 @@ def test_is_zero_is_not_empty(self):
)
move_lines = self.service._find_location_move_lines(zone_location, picking_type)
self.assert_response_select_line(
- response, zone_location, picking_type, move_lines,
+ response,
+ zone_location,
+ picking_type,
+ move_lines,
)
inventory = self.env["stock.inventory"].search(
[
@@ -100,6 +109,7 @@ def test_is_zero_is_not_empty(self):
self.assertEqual(
inventory.name,
"Zero check issue on location {} ({})".format(
- move_line.location_id.name, picking_type.name,
+ move_line.location_id.name,
+ picking_type.name,
),
)
From f5bf25a438dc0c16ab1fc5341312c0ffcf7eb5bd Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Thu, 10 Dec 2020 19:14:44 +0000
Subject: [PATCH 465/940] [UPD] README.rst
---
shopfloor/README.rst | 10 +++++-----
shopfloor/static/description/index.html | 6 +++---
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/shopfloor/README.rst b/shopfloor/README.rst
index db94b7f8d3..b8a2a4657d 100644
--- a/shopfloor/README.rst
+++ b/shopfloor/README.rst
@@ -14,13 +14,13 @@ Shopfloor
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github
- :target: https://github.com/OCA/wms/tree/13.0/shopfloor
+ :target: https://github.com/OCA/wms/tree/14.0/shopfloor
:alt: OCA/wms
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
- :target: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor
+ :target: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
- :target: https://runbot.odoo-community.org/runbot/285/13.0
+ :target: https://runbot.odoo-community.org/runbot/285/14.0
:alt: Try me on Runbot
|badge1| |badge2| |badge3| |badge4| |badge5|
@@ -122,7 +122,7 @@ Bug Tracker
Bugs are tracked on `GitHub Issues `_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
-`feedback `_.
+`feedback `_.
Do not contact contributors directly about support or help with technical issues.
@@ -188,6 +188,6 @@ Current `maintainers `__:
|maintainer-guewen| |maintainer-simahawk| |maintainer-sebalix|
-This module is part of the `OCA/wms `_ project on GitHub.
+This module is part of the `OCA/wms `_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html
index cc20fd6ecc..fc9823dfd8 100644
--- a/shopfloor/static/description/index.html
+++ b/shopfloor/static/description/index.html
@@ -367,7 +367,7 @@ Shopfloor
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
-
+
Shopfloor is a barcode scanner application for internal warehouse operations.
The application supports scenarios, to relate to Operation Types:
@@ -472,7 +472,7 @@
Bugs are tracked on GitHub Issues .
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
-feedback .
+feedback .
Do not contact contributors directly about support or help with technical issues.
From 133f3caa21a9e986007e92e2c4ce2cfb96ae4a0b Mon Sep 17 00:00:00 2001
From: Simone Orsi
Date: Mon, 14 Dec 2020 16:19:22 +0100
Subject: [PATCH 466/940] pre-commit: ignore shopfloor* uninstallable modules
---
shopfloor/__manifest__.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 9735277ec7..a519a8cbc9 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -62,4 +62,5 @@
"demo/shopfloor_menu_demo.xml",
"demo/shopfloor_profile_demo.xml",
],
+ "installable": False,
}
From b7de148842bfdff6fbda9927d460f6e0d2718857 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 20 Jan 2021 08:51:13 +0100
Subject: [PATCH 467/940] Adapt "dispatch" method to new base_rest API with
*args
"_id" has been replaced by *args
---
shopfloor/services/service.py | 44 +++++++++++++++-------------
shopfloor/tests/test_db_logging.py | 4 +--
shopfloor/tests/test_picking_form.py | 4 +--
3 files changed, 27 insertions(+), 25 deletions(-)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index dcfe75f6dc..60e2dfc226 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -56,11 +56,11 @@ class BaseShopfloorService(AbstractComponent):
# can be overridden to disable logging of requests to DB
_log_calls_in_db = True
- def dispatch(self, method_name, _id=None, params=None):
+ def dispatch(self, method_name, *args, params=None):
self._validate_headers_update_work_context(request, method_name)
if not self._db_logging_active():
- return super().dispatch(method_name, _id=_id, params=params)
- return self._dispatch_with_db_logging(method_name, _id=_id, params=params)
+ return super().dispatch(method_name, *args, params=params)
+ return self._dispatch_with_db_logging(method_name, *args, params=params)
def _db_logging_active(self):
return (
@@ -70,45 +70,47 @@ def _db_logging_active(self):
)
# TODO logging to DB should be an extra module for base_rest
- def _dispatch_with_db_logging(self, method_name, _id=None, params=None):
+ def _dispatch_with_db_logging(self, method_name, *args, params=None):
try:
- result = super().dispatch(method_name, _id=_id, params=params)
+ result = super().dispatch(method_name, *args, params=params)
except exceptions.UserError as orig_exception:
self._dispatch_exception(
ShopfloorServiceUserErrorException,
orig_exception,
- _id=_id,
+ *args,
params=params,
)
except exceptions.ValidationError as orig_exception:
self._dispatch_exception(
ShopfloorServiceValidationErrorException,
orig_exception,
- _id=_id,
+ *args,
params=params,
)
except Exception as orig_exception:
self._dispatch_exception(
- ShopfloorServiceDispatchException,
- orig_exception,
- _id=_id,
- params=params,
+ ShopfloorServiceDispatchException, orig_exception, *args, params=params,
)
- log_entry = self._log_call_in_db(self.env, request, _id, params, result=result)
+ log_entry = self._log_call_in_db(
+ self.env, request, *args, params=params, result=result
+ )
log_entry_url = self._get_log_entry_url(log_entry)
result["log_entry_url"] = log_entry_url
return result
- def _dispatch_exception(
- self, exception_klass, orig_exception, _id=None, params=None
- ):
+ def _dispatch_exception(self, exception_klass, orig_exception, *args, params=None):
tb = traceback.format_exc()
# TODO: how to test this? Cannot rollback nor use another cursor
self.env.cr.rollback()
with registry(self.env.cr.dbname).cursor() as cr:
env = self.env(cr=cr)
log_entry = self._log_call_in_db(
- env, request, _id, params, traceback=tb, orig_exception=orig_exception
+ env,
+ request,
+ *args,
+ params=params,
+ traceback=tb,
+ orig_exception=orig_exception,
)
log_entry_url = self._get_log_entry_url(log_entry)
# UserError and alike have `name` attribute to store the msg
@@ -133,14 +135,14 @@ def _get_log_entry_url(self, entry):
def _log_call_header_strip(self):
return ("Cookie", "Api-Key")
- def _log_call_in_db_values(self, _request, _id, params, **kw):
+ def _log_call_in_db_values(self, _request, *args, params=None, **kw):
httprequest = _request.httprequest
headers = dict(httprequest.headers)
for header_key in self._log_call_header_strip:
if header_key in headers:
headers[header_key] = ""
- if _id:
- params = dict(params, _id=_id)
+ if args:
+ params = dict(params or {}, args=args)
result = kw.get("result")
error = kw.get("traceback")
@@ -164,8 +166,8 @@ def _log_call_in_db_values(self, _request, _id, params, **kw):
"state": "success" if result else "failed",
}
- def _log_call_in_db(self, env, _request, _id, params, **kw):
- values = self._log_call_in_db_values(_request, _id, params, **kw)
+ def _log_call_in_db(self, env, _request, *args, params=None, **kw):
+ values = self._log_call_in_db_values(_request, *args, params=params, **kw)
if not values:
return
return env["shopfloor.log"].sudo().create(values)
diff --git a/shopfloor/tests/test_db_logging.py b/shopfloor/tests/test_db_logging.py
index 7a982b9169..737feb3861 100644
--- a/shopfloor/tests/test_db_logging.py
+++ b/shopfloor/tests/test_db_logging.py
@@ -97,7 +97,7 @@ def test_log_entry_values_success(self):
httprequest=httprequest, extra_headers=extra_headers
) as mocked_request:
entry = self.service._log_call_in_db(
- self.env, mocked_request, _id, params, **kw
+ self.env, mocked_request, _id, params=params, **kw
)
expected = {
"request_url": httprequest["url"],
@@ -110,7 +110,7 @@ def test_log_entry_values_success(self):
self.assertRecordValues(entry, [expected])
expected_json = {
"result": {"data": "worked!"},
- "params": dict(params, _id=_id),
+ "params": dict(params, args=[_id]),
"headers": {
"Cookie": "",
"Api-Key": "",
diff --git a/shopfloor/tests/test_picking_form.py b/shopfloor/tests/test_picking_form.py
index 0ab9cc3e8c..8ae8ba677e 100644
--- a/shopfloor/tests/test_picking_form.py
+++ b/shopfloor/tests/test_picking_form.py
@@ -24,7 +24,7 @@ def setUp(self):
def test_picking_form_get(self):
available_carriers = self.service._get_available_carriers(self.picking)
- response = self.service.dispatch("get", _id=self.picking.id)
+ response = self.service.dispatch("get", self.picking.id)
self.assert_response(
response,
data={
@@ -42,7 +42,7 @@ def test_picking_form_update(self):
available_carriers = self.service._get_available_carriers(self.picking)
self.picking.carrier_id = available_carriers[0]
params = {"carrier_id": available_carriers[1].id}
- response = self.service.dispatch("update", _id=self.picking.id, params=params)
+ response = self.service.dispatch("update", self.picking.id, params=params)
self.assert_response(
response,
data={
From 61343fb3e661887211453c2e77d8173a4b6c028c Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 20 Jan 2021 08:56:28 +0100
Subject: [PATCH 468/940] Fix REST services tests
Since version 13.0.3.0.0 of base_rest, we have to setup registries
---
shopfloor/tests/common.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py
index d298be103e..2a445122eb 100644
--- a/shopfloor/tests/common.py
+++ b/shopfloor/tests/common.py
@@ -8,6 +8,7 @@
from odoo.tests.common import Form, SavepointCase
from odoo.addons.base_rest.controllers.main import _PseudoCollection
+from odoo.addons.base_rest.tests.common import RegistryMixin
from odoo.addons.component.core import WorkContext
from odoo.addons.component.tests.common import ComponentMixin
@@ -26,7 +27,7 @@ def __eq__(self, other):
return True
-class CommonCase(SavepointCase, ComponentMixin):
+class CommonCase(SavepointCase, RegistryMixin, ComponentMixin):
"""Base class for writing Shopfloor tests
All tests are run as normal stock user by default, to check that all the
@@ -90,6 +91,7 @@ def setUpClass(cls):
)
cls.setUpComponent()
+ cls.setUpRegistry()
cls.setUpClassUsers()
cls.setUpClassVars()
cls.setUpClassBaseData()
From 93f5057a4fbdc61aced10e0e3e6d015c76f91767 Mon Sep 17 00:00:00 2001
From: Guewen Baconnier
Date: Wed, 20 Jan 2021 08:57:45 +0100
Subject: [PATCH 469/940] Fix validators for new base_rest API
In the initial implementation of rest_api, the schema validators
had to be returned by methods in the same service as the method, named
after the endpoint's method with a prefix: "_validator_" or
"_validator_return_".
As we have a lot of endpoints methods in some services, we extracted
the validator methods in dedicated components with
"base.shopfloor.validator" and "base.shopfloor.validator.return" usages,
and methods of the same name as the endpoint's method.
With the new API, endpoints are decorated with "@restapi.method" and the
validator is defined there. Example:
@restapi.method(
[(["//get", "/"], "GET")],
output_param=restapi.CerberusValidator("_get_partner_schema"),
auth="public",
)
For backward compatilibity, base_rest patches the methods not decorated
and sets the "input_param" and "output_param" to call the
"_validator_" or "_validator_return_":
https://github.com/OCA/rest-framework/blob/abd74cd7241d3b93054825cc3e41cb7b693c9000/base_rest/models/rest_service_registration.py#L240-L250
This change adds the extra layer that will call the validator components
when one of these methods is called.
A follow-up commit will replace the getattr by a better fix with a
change in base_rest, but meanwhile, this commit will unblock the OCA/wms
CI.
---
shopfloor/services/service.py | 46 ++++++++++++++++++++++++++---------
1 file changed, 35 insertions(+), 11 deletions(-)
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 60e2dfc226..5fcd3b0fcc 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -14,6 +14,7 @@
from odoo.addons.base_rest.controllers.main import _PseudoCollection
from odoo.addons.component.core import AbstractComponent, WorkContext
+from odoo.addons.component.exception import NoComponentError
def to_float(val):
@@ -196,19 +197,42 @@ def _to_json(self, records):
res.append(self._convert_one_record(record))
return res
- def _get_input_validator(self, method_name):
- # override the method to get the validator in a component
- # instead of a method, to keep things apart
- validator_component = self.component(usage="%s.validator" % self._usage)
- return validator_component._get_validator(method_name)
-
- def _get_output_validator(self, method_name):
- # override the method to get the validator in a component
- # instead of a method, to keep things apart
+ def _get_validator_schema(self, method_name, usage_suffix):
validator_component = self.component(
- usage="%s.validator.response" % self._usage
+ usage="{}.{}".format(self._usage, usage_suffix)
)
- return validator_component._get_validator(method_name)
+ return getattr(validator_component, method_name)
+
+ # FIXME: must be replaced by a cleaner way to customize the validator
+ # handler, using: https://github.com/OCA/rest-framework/pull/99
+ def __getattr__(self, item):
+ # We have delegated the validator / return validators to dedicated
+ # components. In the new base_rest API, validator schemas are handled
+ # differently, but a backward compatibility layer adds
+ # "_validator_" and "_validator_return_" in the
+ # "routing" of the endpoints, which are automatically called on the
+ # service. As we have no way to replace the current service by the
+ # validator upstream, catch calls to these methods and get the schema
+ # from the validator services.
+ if item.startswith("_validator_return_"):
+ method_name = item.replace("_validator_return_", "")
+ try:
+ schema_handler = self._get_validator_schema(
+ method_name, "validator.response"
+ )
+ except NoComponentError:
+ return super().__getattr__(item)
+ return schema_handler
+
+ if item.startswith("_validator_"):
+ method_name = item.replace("_validator_", "")
+ try:
+ schema_handler = self._get_validator_schema(method_name, "validator")
+ except NoComponentError:
+ return super().__getattr__(item)
+ return schema_handler
+
+ return super().__getattr__(item)
def _response(
self, base_response=None, data=None, next_state=None, message=None, popup=None
From 46227308ccef680cd33e9ca9a7bbc0235dc14f19 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Fri, 8 Jan 2021 09:29:26 +0100
Subject: [PATCH 470/940] shopfloor: create normal backorder when validating
moves
Before: when moves are processed, we were managing two cases:
- if the current moves to process are all the moves remaining in a
transfer, we validate the transfer
- if the current moves to process are a subset of available moves of a
transfer, then we put these moves to process in a new transfer which
is validated. All remaining moves stay in the current transfer.
After: a third case is added:
- if the current moves to process are equal to all available moves of
the current picking - but there are still unavailable moves to process -
then we validate the current transfer as usual, which will create a
backorder with remaining/unavailable moves.
As this third case is very specific to Shopfloor, it has been
implemented in a new StockAction component (used by all related scenarios).
Now all scenarios use this new component to validate stock moves,
and the `extract_and_action_done` method on `stock.move` can stay generic.
The opportunity was taken to create a new method `split_unavailable_qty`
on `stock.move` to put unavailable qty to a new move and update the
current move to `assigned` (it was done directly in
`extract_and_action_done` before).
---
shopfloor/actions/__init__.py | 1 +
shopfloor/actions/stock.py | 41 +++++++++++++++++++
shopfloor/models/stock_move.py | 20 ++++++---
.../services/location_content_transfer.py | 6 ++-
shopfloor/services/single_pack_transfer.py | 3 +-
shopfloor/services/zone_picking.py | 9 ++--
shopfloor/tests/test_stock_split.py | 4 +-
.../tests/test_zone_picking_unload_all.py | 11 ++---
...est_zone_picking_unload_set_destination.py | 10 +++--
9 files changed, 82 insertions(+), 23 deletions(-)
create mode 100644 shopfloor/actions/stock.py
diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py
index 788ec5e8a9..43f307a745 100644
--- a/shopfloor/actions/__init__.py
+++ b/shopfloor/actions/__init__.py
@@ -27,3 +27,4 @@
from . import inventory
from . import savepoint
from . import move_line_search
+from . import stock
diff --git a/shopfloor/actions/stock.py b/shopfloor/actions/stock.py
new file mode 100644
index 0000000000..54e9f0b848
--- /dev/null
+++ b/shopfloor/actions/stock.py
@@ -0,0 +1,41 @@
+# Copyright 2020 Camptocamp SA (http://www.camptocamp.com)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo.addons.component.core import Component
+
+
+class StockAction(Component):
+ """Provide methods to work with stock operations."""
+
+ _name = "shopfloor.stock.action"
+ _inherit = "shopfloor.process.action"
+ _usage = "stock"
+
+ def validate_moves(self, moves):
+ """Validate moves in different ways depending on several criterias:
+
+ - moves to process are all the moves of the related transfer:
+ the current transfer is validated
+ - moves to process are a subset of available moves in the picking:
+ the moves are put in a new transfer which is validated, the current
+ transfer still have the remaining moves
+ - moves to process are exactly the assigned moves of the related transfer:
+ the transfer is validated as usual, creating a backorder.
+ """
+ moves.split_unavailable_qty()
+ for picking in moves.picking_id:
+ moves_todo = picking.move_lines & moves
+ if self._check_backorder(picking, moves_todo):
+ picking.action_done()
+ else:
+ moves_todo.extract_and_action_done()
+
+ def _check_backorder(self, picking, moves):
+ """Check if the `picking` has to be validated as usual to create a backorder.
+
+ If the moves are equal to all available moves of the current picking
+ - but there are still unavailable moves to process - then we want to
+ create a normal backorder (i.e. the current picking is validated and
+ the remaining moves are put in a backorder as usual)
+ """
+ assigned_moves = picking.move_lines.filtered(lambda m: m.state == "assigned")
+ return moves == assigned_moves
diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py
index 20473b6f26..26f34fbc2b 100644
--- a/shopfloor/models/stock_move.py
+++ b/shopfloor/models/stock_move.py
@@ -37,6 +37,15 @@ def split_other_move_lines(self, move_lines, intersection=False):
return backorder_move
return False
+ def split_unavailable_qty(self):
+ """Put unavailable qty of a partially available move in their own
+ move (which will be 'confirmed').
+ """
+ partial_moves = self.filtered(lambda m: m.state == "partially_available")
+ for partial_move in partial_moves:
+ partial_move.split_other_move_lines(partial_move.move_line_ids)
+ return partial_moves
+
def extract_and_action_done(self):
"""Extract the moves in a separate transfer and validate them.
@@ -44,20 +53,19 @@ def extract_and_action_done(self):
to first extract some move lines in a separate move, then validate it
with this method.
"""
- # Put remaining qty to process from partially available moves
- # in their own move (which will be then 'confirmed')
- partial_moves = self.filtered(lambda m: m.state == "partially_available")
- for partial_move in partial_moves:
- partial_move.split_other_move_lines(partial_move.move_line_ids)
# Process assigned moves
moves = self.filtered(lambda m: m.state == "assigned")
if not moves:
return False
for picking in moves.picking_id:
moves_todo = picking.move_lines & moves
+ # No need to create a new transfer if we are processing all moves
if moves_todo == picking.move_lines:
- # No need to create a new transfer if we are processing all moves
new_picking = picking
+ # We process some available moves of the picking, but there are still
+ # some other moves to process, then we put the moves to process in
+ # a new transfer to validate. All remaining moves stay in the
+ # current transfer.
else:
new_picking = picking.copy(
{
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index c2a8dbe4c2..4fafb5e0c2 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -656,7 +656,8 @@ def set_destination_package(
# split the move to process only the lines related to the package.
package_move.split_other_move_lines(package_move_lines)
self._write_destination_on_lines(package_level.move_line_ids, scanned_location)
- package_moves.extract_and_action_done()
+ stock = self.actions_for("stock")
+ stock.validate_moves(package_moves)
move_lines = self._find_transfer_move_lines(location)
message = self.msg_store.location_content_transfer_item_complete(
scanned_location
@@ -730,7 +731,8 @@ def set_destination_line(
remaining_move_line.qty_done = remaining_move_line.product_uom_qty
move_line.move_id.split_other_move_lines(move_line)
self._write_destination_on_lines(move_line, scanned_location)
- move_line.move_id.extract_and_action_done()
+ stock = self.actions_for("stock")
+ stock.validate_moves(move_line.move_id)
move_lines = self._find_transfer_move_lines(location)
message = self.msg_store.location_content_transfer_item_complete(
scanned_location
diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py
index b0c7a6faa2..917d47e8a7 100644
--- a/shopfloor/services/single_pack_transfer.py
+++ b/shopfloor/services/single_pack_transfer.py
@@ -267,7 +267,8 @@ def _set_destination_and_done(self, move, scanned_location):
# when writing the destination on the package level, it writes
# on the move lines
move.move_line_ids.package_level_id.location_dest_id = scanned_location
- move.extract_and_action_done()
+ stock = self.actions_for("stock")
+ stock.validate_moves(move)
def cancel(self, package_level_id):
package_level = self.env["stock.package_level"].browse(package_level_id)
diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py
index 2ef04204a6..cb01aa3168 100644
--- a/shopfloor/services/zone_picking.py
+++ b/shopfloor/services/zone_picking.py
@@ -566,7 +566,8 @@ def _set_destination_location(
# try to re-assign any split move (in case of partial qty)
if "confirmed" in move_line.picking_id.move_lines.mapped("state"):
move_line.picking_id.action_assign()
- move_line.move_id.extract_and_action_done()
+ stock = self.actions_for("stock")
+ stock.validate_moves(move_line.move_id)
location_changed = True
# Zero check
zero_check = picking_type.shopfloor_zero_check
@@ -1095,7 +1096,8 @@ def set_destination_all(
self._write_destination_on_lines(buffer_lines, location)
# set lines to done + refresh buffer lines (should be empty)
moves = buffer_lines.mapped("move_id")
- moves.extract_and_action_done()
+ stock = self.actions_for("stock")
+ stock.validate_moves(moves)
message = self.msg_store.buffer_complete()
buffer_lines = self._find_buffer_move_lines(zone_location, picking_type)
else:
@@ -1302,7 +1304,8 @@ def unload_set_destination(
for move in moves:
move.split_other_move_lines(buffer_lines & move.move_line_ids)
- moves.extract_and_action_done()
+ stock = self.actions_for("stock")
+ stock.validate_moves(moves)
buffer_lines = self._find_buffer_move_lines(zone_location, picking_type)
if buffer_lines:
diff --git a/shopfloor/tests/test_stock_split.py b/shopfloor/tests/test_stock_split.py
index af2c925d5c..d4f099045f 100644
--- a/shopfloor/tests/test_stock_split.py
+++ b/shopfloor/tests/test_stock_split.py
@@ -171,7 +171,7 @@ def test_split_pickings_from_source_location(self):
set(self.pack_move_a.move_line_ids.mapped("state")), {"assigned"}
)
- def test_extract_and_action_done_one_move(self):
+ def test_extract_and_action_done_one_assigned_move(self):
self.assertFalse(self.picking.backorder_ids)
self.assertEqual(self.picking.state, "assigned")
for move_line in self.pick_move_b.move_line_ids:
@@ -187,7 +187,7 @@ def test_extract_and_action_done_one_move(self):
self.assertEqual(self.pick_move_b.state, "done")
self.assertEqual(new_picking.state, "done")
- def test_extract_and_action_done_several_moves(self):
+ def test_extract_and_action_done_multiple_assigned_moves(self):
self.assertFalse(self.picking.backorder_ids)
self.assertEqual(self.picking.state, "assigned")
for move_line in self.picking.move_line_ids:
diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py
index 33997cf93d..8880b4dcda 100644
--- a/shopfloor/tests/test_zone_picking_unload_all.py
+++ b/shopfloor/tests/test_zone_picking_unload_all.py
@@ -265,7 +265,7 @@ def test_set_destination_all_partial_qty_done_ok(self):
)
# check data
# picking validated
- picking_validated = self.picking6.backorder_ids
+ picking_validated = self.picking6
self.assertEqual(picking_validated.state, "done")
self.assertEqual(picking_validated.move_line_ids, move_line_g | move_line_h)
self.assertEqual(move_line_g.state, "done")
@@ -273,10 +273,11 @@ def test_set_destination_all_partial_qty_done_ok(self):
self.assertEqual(move_line_h.state, "done")
self.assertEqual(move_line_h.qty_done, 3)
# current picking (backorder)
- self.assertEqual(self.picking6.state, "confirmed")
- self.assertEqual(self.picking6.move_lines.product_id, self.product_h)
- self.assertEqual(self.picking6.move_lines.product_uom_qty, 3)
- self.assertFalse(self.picking6.move_line_ids)
+ backorder = self.picking6.backorder_ids
+ self.assertEqual(backorder.state, "confirmed")
+ self.assertEqual(backorder.move_lines.product_id, self.product_h)
+ self.assertEqual(backorder.move_lines.product_uom_qty, 3)
+ self.assertFalse(backorder.move_line_ids)
# buffer should be empty
buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type)
self.assertFalse(buffer_lines)
diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py
index 5e6b935032..f0c475cb1e 100644
--- a/shopfloor/tests/test_zone_picking_unload_set_destination.py
+++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py
@@ -361,11 +361,13 @@ def test_unload_set_destination_partially_available_backorder(self):
)
# check data
# move line has been moved to a new picking
- self.assertEqual(move_line.move_id.picking_id, self.picking_z.backorder_ids[0])
- # the old picking contains a new line w/ the rest of the qty
+ # move line has been validated in the current picking
+ self.assertEqual(move_line.move_id.picking_id, self.picking_z)
+ # the new picking (backorder) contains a new line w/ the rest of the qty
# that couldn't be processed
- self.assertEqual(self.picking_z.move_lines[0].product_uom_qty, 8)
- self.assertEqual(self.picking_z.state, "confirmed")
+ backorder = self.picking_z.backorder_ids[0]
+ self.assertEqual(backorder.move_lines[0].product_uom_qty, 8)
+ self.assertEqual(backorder.state, "confirmed")
# the line has been processed
self.assertEqual(move_line.location_dest_id, packing_sublocation)
self.assertEqual(move_line.move_id.state, "done")
From 72d00f4356687f0be1903a3f21e6146dbaea7a50 Mon Sep 17 00:00:00 2001
From: Ignacio Buioli
Date: Sat, 6 Feb 2021 18:33:27 +0000
Subject: [PATCH 471/940] Added translation using Weblate (Spanish (Argentina))
---
shopfloor/i18n/es_AR.po | 1417 +++++++++++++++++++++++++++++++++++++++
1 file changed, 1417 insertions(+)
create mode 100644 shopfloor/i18n/es_AR.po
diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po
new file mode 100644
index 0000000000..cfb0be0099
--- /dev/null
+++ b/shopfloor/i18n/es_AR.po
@@ -0,0 +1,1417 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * shopfloor
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 13.0\n"
+"Report-Msgid-Bugs-To: \n"
+"Last-Translator: Automatically generated\n"
+"Language-Team: none\n"
+"Language: es_AR\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/forms/form_mixin.py:0
+#, python-format
+msgid "%s updated."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "A destination package is required."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/change_package_lot.py:0
+#, python-format
+msgid "A draft inventory has been created for control."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check
+msgid "Activate Zero Check"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__active
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__active
+msgid "Active"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin
+msgid "Adds shopfloor priority/postpone fields"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "All packages processed."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create
+msgid "Allow Move Creation"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves
+msgid "Allow to process reserved quantities"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_form_view
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_search_view
+msgid "Archived"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Are you sure?"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.actions.server,name:shopfloor.ir_cron_autovacuum_shopfloor_log_ir_actions_server
+#: model:ir.cron,cron_name:shopfloor.ir_cron_autovacuum_shopfloor_log
+#: model:ir.cron,name:shopfloor.ir_cron_autovacuum_shopfloor_log
+msgid "Auto-vacuum Shopfloor Logs"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Barcode does not match with {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Barcode not found"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_picking_batch
+msgid "Batch Transfer"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Batch Transfer complete"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Batch Transfer line done"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Bin %s doesn't exist"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Canceled, you can scan a new pack."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Cannot change to lot {} which is entirely picked."
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_checkout
+#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo
+msgid "Checkout"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_res_partner__shopfloor_packing_info
+#: model:ir.model.fields,field_description:shopfloor.field_res_users__shopfloor_packing_info
+msgid "Checkout Packing Information"
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_cluster_picking
+#: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo
+msgid "Cluster Picking"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Confirm location change from %s to %s?"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_res_partner
+msgid "Contact"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Content transfer to {} completed"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Content transferred from {} to {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/inventory.py:0
+#, python-format
+msgid "Control stock issue in location {} for {}"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_uid
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_uid
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_uid
+msgid "Created by"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_move.py:0
+#, python-format
+msgid ""
+"Created from backorder %s ."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_date
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_date
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_date
+msgid "Created on"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/shopfloor_menu.py:0
+#, python-format
+msgid "Creation of moves is not allowed for menu {}."
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Date"
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery
+#: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo
+msgid "Delivery"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__display_name
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__display_name
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__display_name
+msgid "Display Name"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_display_packing_info
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_display_packing_info
+msgid "Display customer packing info"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__error
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view
+msgid "Error"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_name
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Exception"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_message
+msgid "Exception Message"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Exception message"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__failed
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Failed"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check
+msgid ""
+"For Shopfloor scenarios using it (Cluster Picking, Zone Picking, Discrete "
+"order Picking), the zero check step will be activated when a location "
+"becomes empty after a move."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_picking__shopfloor_display_packing_info
+#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_display_packing_info
+msgid ""
+"For the Shopfloor Checkout/Packing scenarios to display the customer packing"
+" info."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id
+msgid "From"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__functional
+msgid "Functional"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Functional errors"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Group By"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__headers
+msgid "Headers"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__id
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__id
+msgid "ID"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available
+msgid ""
+"If you tick this box, the transfer is reserved only if the put-away can find"
+" a sublocation (when putaway destination is different from the operation "
+"type's destination)."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves
+msgid ""
+"If you tick this box, this scenario will allow operator to move goods even "
+"if a reservation is made by a different operation type."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible
+msgid "Ignore No Putaway Available Is Possible"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available
+msgid "Ignore transfers when no put-away is available"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/shopfloor_menu.py:0
+#, python-format
+msgid "Ignoring not found putaway is not allowed for menu {}."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_inventory
+msgid "Inventory"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_location
+msgid "Inventory Locations"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log____last_update
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu____last_update
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile____last_update
+msgid "Last Modified on"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_uid
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_uid
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_uid
+msgid "Last Updated by"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_date
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_date
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_date
+msgid "Last Updated on"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/completion_info.py:0
+#, python-format
+msgid ""
+"Last operation of transfer {}. Next operation ({}) is ready to proceed."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Line cancelled"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Lines have different destination location."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Location %s doesn't contain any package."
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_location_content_transfer
+#: model:stock.picking.type,name:shopfloor.picking_type_location_content_transfer_demo
+msgid "Location Content Transfer"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Location not allowed here."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Location {} empty"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Logs generated today"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Lot is not in the current transfer."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/delivery.py:0
+#, python-format
+msgid "Lot {} belongs to a picking without a valid state."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Lot {} is for another product."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Lot {} replaced by lot {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/inventory.py:0
+#, python-format
+msgid "Lot: "
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_shopfloor_menu
+msgid "Menu displayed in the scanner application"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_menu
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__menu_ids
+#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_menu
+msgid "Menus"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_profile__menu_ids
+msgid "Menus visible for this profile"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible
+msgid "Move Create Is Possible"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids
+msgid "Move Line"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__move_line_count
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__move_line_count
+msgid "Move Line Count"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_move_line.py:0
+#, python-format
+msgid "Move lines processed have to share the same source location."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__name
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__name
+msgid "Name"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Negative quantity not allowed."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "New move lines cannot be assigned: canceled."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No lines to process."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No location found for this barcode."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No lot found among current transfers."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No lot found for {}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/cluster_picking.py:0
+#, python-format
+msgid "No more work to do, please create a new batch transfer"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No operation type found for this menu and profile."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/service.py:0
+#, python-format
+msgid "No operation types configured on menu {} for warehouse {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No package or lot found for barcode {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No pending operation for package %s."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No product found among current transfers."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No putaway destination is available."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "No quantity has been processed, unable to complete the transfer."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "No valid package to select."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Not a valid destination package"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid ""
+"Not all lines have been processed with full quantity. Do you confirm partial"
+" operation?"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid ""
+"Not allowed to pack more than the quantity, the value has been changed to "
+"the maximum."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__picking_type_ids
+msgid "Operation Types"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Operation already processed."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Operation's already running. Would you like to take it over?"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Package cancelled"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package has been opened. You can move partial quantities."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/location_content_transfer.py:0
+#, python-format
+msgid "Package level has to be in draft"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_quant_package.py:0
+#, python-format
+msgid "Package name must be unique!"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/delivery.py:0
+#, python-format
+msgid "Package {} belongs to a picking without a valid state."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} cannot be picked, already moved by transfer {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} cannot be used: {} "
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_move_line.py:0
+#, python-format
+msgid ""
+"Package {} does not contain available product {}, cannot replace package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} has a different content."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_move_line.py:0
+#, python-format
+msgid "Package {} has been partially picked in another location"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} is already used."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} is not available in transfer {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} is not empty."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Package {} is not in the current transfer."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Package {} replaced by package {}."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_quant_package
+msgid "Packages"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Packaging changed on package {}"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_packing_info
+msgid "Packing information"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view
+msgid "Parameters"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__params
+msgid "Params"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/change_package_lot.py:0
+#, python-format
+msgid "Pick: stock issue on lot: {} found in {}"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count
+msgid "Picking Count"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_picking_type
+msgid "Picking Type"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Picking has already been started in this location in transfer(s): {}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Picking type {} complete."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids
+msgid "Planned Move Line"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/shopfloor_menu.py:0
+#, python-format
+msgid "Processing reserved quantities is not allowed for menu {}."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_move_line
+msgid "Product Moves (Stock Move Line)"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Product is not in the current transfer."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Product tracked by lot, please scan one."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/delivery.py:0
+#, python-format
+msgid "Product {} belongs to a picking without a valid state."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Product(s) packed in {}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Product(s) processed as raw product(s)"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_profile
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__profile_ids
+#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_profile
+msgid "Profiles"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_quant
+msgid "Quants"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/scan_anything.py:0
+#, python-format
+msgid ""
+"Record not found.\n"
+"We've tried with the following types: {}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Recovered previous session."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid "Remaining raw product not packed, proceed anyway?"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_method
+msgid "Request Method"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_url
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Request URL"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_location__reserved_move_line_ids
+#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__reserved_move_line_ids
+msgid "Reserved Move Line"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Restart the operation, someone has canceled it."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__result
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view
+msgid "Result"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree
+msgid "SF Priority"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree
+msgid "SF checkout done"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree
+msgid "SF unloaded"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Scan the destination location"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/location_content_transfer.py:0
+#: code:addons/shopfloor/services/location_content_transfer.py:0
+#, python-format
+msgid "Scan the package"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scenario
+msgid "Scenario"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view
+msgid "Scenario Options"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/shopfloor_menu.py:0
+#, python-format
+msgid ""
+"Scenario `{}` require(s) 'Move Entire Packages' to be enabled.\n"
+"These type(s) do not satisfy this constraint: \n"
+"{}.\n"
+"Please, adjust your configuration."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__sequence
+msgid "Sequence"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Several lots found in %s, please scan a lot."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Several operation types found for this menu and profile."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Several packages found in %s, please scan a package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Several products found in %s, please scan a product."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/checkout.py:0
+#, python-format
+msgid ""
+"Several transfers found, please scan a package or select a transfer "
+"manually."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__severe
+msgid "Severe"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Severe errors"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__severity
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Severity"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.ui.menu,name:shopfloor.menu_shopfloor_settings
+#: model_terms:ir.ui.view,arch_db:shopfloor.res_partner_shopfloor_form
+#: model_terms:ir.ui.view,arch_db:shopfloor.view_location_form
+#: model_terms:ir.ui.view,arch_db:shopfloor.view_picking_type_form
+msgid "Shopfloor"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done
+msgid "Shopfloor Checkout Done"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_shopfloor_log
+msgid "Shopfloor Logging"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_log
+#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_log
+msgid "Shopfloor Logs"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids
+msgid "Shopfloor Menus"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence
+msgid "Shopfloor Picking Sequence"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_postponed
+#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_postponed
+msgid "Shopfloor Postponed"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_priority
+#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_priority
+msgid "Shopfloor Priority"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded
+msgid "Shopfloor Unloaded"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id
+msgid "Shopfloor User"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_shopfloor_profile
+msgid "Shopfloor profile settings"
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_single_pallet_transfer
+#: model:stock.picking.type,name:shopfloor.picking_type_single_pallet_transfer_demo
+msgid "Single Pallet Transfer"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create
+msgid ""
+"Some scenario may create move(s) when a product or package is scanned and no"
+" move already exists. Any new move is created in the selected operation "
+"type, so it can be active only when one type is selected."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_location__source_move_line_ids
+msgid "Source Move Line"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id
+msgid "Source Package"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__state
+msgid "State"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Status"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_move
+msgid "Stock Move"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_package_level
+msgid "Stock Package Level"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id
+msgid "Stock Picking"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__success
+msgid "Success"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed
+#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_postponed
+#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_postponed
+msgid ""
+"Technical field. Indicates if the operation has been postponed in a barcode "
+"scenario."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_picking__move_line_count
+#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__move_line_count
+msgid "Technical field. Indicates number of move lines included."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count
+msgid "Technical field. Indicates number of transfers included."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_picking__total_weight
+#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__total_weight
+msgid "Technical field. Indicates total weight of transfers included."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__planned_move_line_ids
+msgid "Technical field. Move lines for which destination is this package."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__move_line_ids
+msgid "Technical field. Move lines moving this package."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority
+#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_priority
+#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_priority
+msgid "Technical field. Overrides operation priority in barcode scenario."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/models/stock_move_line.py:0
+#, python-format
+msgid ""
+"The backorder %s has been created."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/cluster_picking.py:0
+#, python-format
+msgid "The destination bin {} is not empty, please take another."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "The pack has been moved, you can scan a new pack."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "The package %s doesn't exist"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence
+msgid ""
+"The picking done in Shopfloor scenarios will respect this order. The "
+"sequence is a char so it can be composed of fields such as 'corridor-rack-"
+"side-level'. Pay attention to the padding ('09' is before '19', '9' is not)."
+" It is recommended to use an Export then an Import to populate this field "
+"using a spreadsheet."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/service.py:0
+#, python-format
+msgid "The record %s %s does not exist"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "The record you were working on does not exist anymore."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_stock_move_line__picking_id
+msgid "The stock operation where the packing has been made"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/cluster_picking.py:0
+#, python-format
+msgid "This batch cannot be selected."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This line has a package, please select the package instead."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This line is not available in transfer {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/location_content_transfer.py:0
+#, python-format
+msgid "This location content can't be moved at once."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/location_content_transfer.py:0
+#, python-format
+msgid "This location content can't be moved using this menu."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This lot does not exist anymore."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid ""
+"This lot is part of a package with other products, please scan a package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This lot is part of multiple packages, please scan a package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This operation does not exist anymore."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This package does not exist anymore."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This product does not exist anymore."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid ""
+"This product is part of a package with other products, please scan a "
+"package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This product is part of multiple packages, please scan a package."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "This transfer does not exist or is not available anymore."
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Today"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__total_weight
+#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__total_weight
+msgid "Total Weight"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model,name:shopfloor.model_stock_picking
+msgid "Transfer"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Transfer {} complete"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Transfer {} done"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Transfer {} is not available."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Units replaced by package {}."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "Unrecoverable error, please restart."
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unreserve_other_moves_is_possible
+msgid "Unreserve Other Moves Is Possible"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "User"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_ids
+msgid "Visible for these profiles"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__warehouse_id
+msgid "Warehouse"
+msgstr ""
+
+#. module: shopfloor
+#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__warning
+msgid "Warning"
+msgstr ""
+
+#. module: shopfloor
+#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
+msgid "Warning errors"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/services/cluster_picking.py:0
+#, python-format
+msgid "Wrong bin"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "You cannot move this using this menu."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "You cannot place it here"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "You cannot work on a package (%s) outside of locations: %s"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "You must not pick more than {} units."
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/inventory.py:0
+#, python-format
+msgid "Zero check issue on location {}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/inventory.py:0
+#, python-format
+msgid "Zero check issue on location {} ({})"
+msgstr ""
+
+#. module: shopfloor
+#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_zone_picking
+#: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo
+msgid "Zone Picking"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/inventory.py:0
+#, python-format
+msgid ""
+"{picking.name} stock correction in location {location.name} for "
+"{product_desc}"
+msgstr ""
+
+#. module: shopfloor
+#: code:addons/shopfloor/actions/message.py:0
+#, python-format
+msgid "{} {} put in {}"
+msgstr ""
From 4d566982721bb6cf25635292104ad70c65b8d23a Mon Sep 17 00:00:00 2001
From: Ignacio Buioli
Date: Sat, 6 Feb 2021 20:06:38 +0000
Subject: [PATCH 472/940] Translated using Weblate (Spanish (Argentina))
Currently translated at 100.0% (235 of 235 strings)
Translation: wms-14.0/wms-14.0-shopfloor
Translate-URL: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor/es_AR/
---
shopfloor/i18n/es_AR.po | 476 ++++++++++++++++++++++------------------
1 file changed, 267 insertions(+), 209 deletions(-)
diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po
index cfb0be0099..ac41377758 100644
--- a/shopfloor/i18n/es_AR.po
+++ b/shopfloor/i18n/es_AR.po
@@ -6,63 +6,65 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 13.0\n"
"Report-Msgid-Bugs-To: \n"
-"Last-Translator: Automatically generated\n"
+"PO-Revision-Date: 2021-02-06 22:44+0000\n"
+"Last-Translator: Ignacio Buioli \n"
"Language-Team: none\n"
"Language: es_AR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.3.2\n"
#. module: shopfloor
#: code:addons/shopfloor/services/forms/form_mixin.py:0
#, python-format
msgid "%s updated."
-msgstr ""
+msgstr "%s actualizado."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "A destination package is required."
-msgstr ""
+msgstr "Un paquete de destino es requerido."
#. module: shopfloor
#: code:addons/shopfloor/actions/change_package_lot.py:0
#, python-format
msgid "A draft inventory has been created for control."
-msgstr ""
+msgstr "Se ha creado un borrador de inventario para su control."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check
msgid "Activate Zero Check"
-msgstr ""
+msgstr "Activar Verificación Cero"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__active
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__active
msgid "Active"
-msgstr ""
+msgstr "Activo"
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin
msgid "Adds shopfloor priority/postpone fields"
-msgstr ""
+msgstr "Agrega campos de prioridad / aplazamiento del taller"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "All packages processed."
-msgstr ""
+msgstr "Todos los paquetes procesados."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create
msgid "Allow Move Creation"
-msgstr ""
+msgstr "Permitir Creación de Movimiento"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves
msgid "Allow to process reserved quantities"
-msgstr ""
+msgstr "Permitir procesar cantidades reservadas"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view
@@ -70,121 +72,121 @@ msgstr ""
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_form_view
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_profile_search_view
msgid "Archived"
-msgstr ""
+msgstr "Archivado"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Are you sure?"
-msgstr ""
+msgstr "¿Está seguro?"
#. module: shopfloor
#: model:ir.actions.server,name:shopfloor.ir_cron_autovacuum_shopfloor_log_ir_actions_server
#: model:ir.cron,cron_name:shopfloor.ir_cron_autovacuum_shopfloor_log
#: model:ir.cron,name:shopfloor.ir_cron_autovacuum_shopfloor_log
msgid "Auto-vacuum Shopfloor Logs"
-msgstr ""
+msgstr "Eliminación Automática de Registros del Taller"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Barcode does not match with {}."
-msgstr ""
+msgstr "Código de barras no coincide con {}."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Barcode not found"
-msgstr ""
+msgstr "Código de barras no encontrado"
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_stock_picking_batch
msgid "Batch Transfer"
-msgstr ""
+msgstr "Transferencia por Lotes"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Batch Transfer complete"
-msgstr ""
+msgstr "Transferencia por Lotes completa"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Batch Transfer line done"
-msgstr ""
+msgstr "Línea de Transferencia por lotes hecha"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Bin %s doesn't exist"
-msgstr ""
+msgstr "Compartimento %s no existe"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Canceled, you can scan a new pack."
-msgstr ""
+msgstr "Cancelado, puede escanear un nuevo paquete."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Cannot change to lot {} which is entirely picked."
-msgstr ""
+msgstr "No se puede cambiar al lote {} ya que está completamente recogido."
#. module: shopfloor
#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_checkout
#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo
msgid "Checkout"
-msgstr ""
+msgstr "Checkout"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_res_partner__shopfloor_packing_info
#: model:ir.model.fields,field_description:shopfloor.field_res_users__shopfloor_packing_info
msgid "Checkout Packing Information"
-msgstr ""
+msgstr "Información del Paquete del Checkout"
#. module: shopfloor
#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_cluster_picking
#: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo
msgid "Cluster Picking"
-msgstr ""
+msgstr "Grupo de Picking"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Confirm location change from %s to %s?"
-msgstr ""
+msgstr "¿Confirma cambiar ubicación desde %s hacia %s?"
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_res_partner
msgid "Contact"
-msgstr ""
+msgstr "Contacto"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Content transfer to {} completed"
-msgstr ""
+msgstr "Transferencia de contenido a {} completada"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Content transferred from {} to {}."
-msgstr ""
+msgstr "Transferencia de contenido desde {} hacia {}."
#. module: shopfloor
#: code:addons/shopfloor/actions/inventory.py:0
#, python-format
msgid "Control stock issue in location {} for {}"
-msgstr ""
+msgstr "Error en control de inventario en ubicación {} para {}"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_uid
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_uid
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_uid
msgid "Created by"
-msgstr ""
+msgstr "Creado por"
#. module: shopfloor
#: code:addons/shopfloor/models/stock_move.py:0
@@ -193,30 +195,32 @@ msgid ""
"Created from backorder %s ."
msgstr ""
+"Creado desde pedido pendiente %s ."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_date
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__create_date
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__create_date
msgid "Created on"
-msgstr ""
+msgstr "Creado el"
#. module: shopfloor
#: code:addons/shopfloor/models/shopfloor_menu.py:0
#, python-format
msgid "Creation of moves is not allowed for menu {}."
-msgstr ""
+msgstr "La creación de movimientos no está permitida para el menú {}."
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Date"
-msgstr ""
+msgstr "Fecha"
#. module: shopfloor
#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery
#: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo
msgid "Delivery"
-msgstr ""
+msgstr "Entrega"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__display_name
@@ -224,41 +228,41 @@ msgstr ""
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__display_name
msgid "Display Name"
-msgstr ""
+msgstr "Mostrar Nombre"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_display_packing_info
#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_display_packing_info
msgid "Display customer packing info"
-msgstr ""
+msgstr "Mostrar información del empaquetado de cliente"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__error
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view
msgid "Error"
-msgstr ""
+msgstr "Error"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_name
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Exception"
-msgstr ""
+msgstr "Excepción"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_message
msgid "Exception Message"
-msgstr ""
+msgstr "Mensaje de Excepción"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Exception message"
-msgstr ""
+msgstr "Mensaje de Excepción"
#. module: shopfloor
#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__failed
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Failed"
-msgstr ""
+msgstr "Fallido"
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check
@@ -267,6 +271,9 @@ msgid ""
"order Picking), the zero check step will be activated when a location "
"becomes empty after a move."
msgstr ""
+"Para los escenarios del Taller que lo utilizan (Selección de grupos, "
+"Selección de zonas, Selección de pedidos discretos), el paso de verificación "
+"cero se activará cuando una ubicación quede vacía después de un movimiento."
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_stock_picking__shopfloor_display_packing_info
@@ -275,31 +282,33 @@ msgid ""
"For the Shopfloor Checkout/Packing scenarios to display the customer packing"
" info."
msgstr ""
+"Para que los escenarios de Checkout/Empaquetado del Taller muestren la "
+"información de empaquetado del cliente."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id
msgid "From"
-msgstr ""
+msgstr "Desde"
#. module: shopfloor
#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__functional
msgid "Functional"
-msgstr ""
+msgstr "Funcional"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Functional errors"
-msgstr ""
+msgstr "Errores funcionales"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Group By"
-msgstr ""
+msgstr "Agrupar por"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__headers
msgid "Headers"
-msgstr ""
+msgstr "Cabeceras"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id
@@ -307,7 +316,7 @@ msgstr ""
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__id
msgid "ID"
-msgstr ""
+msgstr "ID"
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available
@@ -316,6 +325,9 @@ msgid ""
" a sublocation (when putaway destination is different from the operation "
"type's destination)."
msgstr ""
+"Si marca esta casilla, la transferencia se reserva solo si la ubicación "
+"puede encontrar una sububicación (cuando el destino de la ubicación es "
+"diferente del destino del tipo de operación)."
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves
@@ -323,32 +335,34 @@ msgid ""
"If you tick this box, this scenario will allow operator to move goods even "
"if a reservation is made by a different operation type."
msgstr ""
+"Si marca esta casilla, este escenario permitirá al operador mover mercancías "
+"incluso si se realiza una reserva mediante un tipo de operación diferente."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible
msgid "Ignore No Putaway Available Is Possible"
-msgstr ""
+msgstr "Ignorar que No Hay Almacenamiento Disponible Es Posible"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available
msgid "Ignore transfers when no put-away is available"
-msgstr ""
+msgstr "Ignora las transferencias cuando no haya disponibilidad de ubicación"
#. module: shopfloor
#: code:addons/shopfloor/models/shopfloor_menu.py:0
#, python-format
msgid "Ignoring not found putaway is not allowed for menu {}."
-msgstr ""
+msgstr "No se permite ignorar el almacenamiento no encontrado para el menú {}."
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_stock_inventory
msgid "Inventory"
-msgstr ""
+msgstr "Inventario"
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_stock_location
msgid "Inventory Locations"
-msgstr ""
+msgstr "Ubicaciones de Inventario"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log____last_update
@@ -356,21 +370,21 @@ msgstr ""
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile____last_update
msgid "Last Modified on"
-msgstr ""
+msgstr "Última Modificación el"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_uid
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_uid
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_uid
msgid "Last Updated by"
-msgstr ""
+msgstr "Última Actualización por"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_date
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__write_date
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__write_date
msgid "Last Updated on"
-msgstr ""
+msgstr "Última Actualización el"
#. module: shopfloor
#: code:addons/shopfloor/actions/completion_info.py:0
@@ -378,219 +392,226 @@ msgstr ""
msgid ""
"Last operation of transfer {}. Next operation ({}) is ready to proceed."
msgstr ""
+"Última operación de transferencia: {}. Siguiente operación ({}) está lista "
+"para proceder."
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "Line cancelled"
-msgstr ""
+msgstr "Línea cancelada"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Lines have different destination location."
-msgstr ""
+msgstr "La líneas tiene diferente ubicación de destino."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Location %s doesn't contain any package."
-msgstr ""
+msgstr "La Ubicación %s no contiene ningún paquete."
#. module: shopfloor
#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_location_content_transfer
#: model:stock.picking.type,name:shopfloor.picking_type_location_content_transfer_demo
msgid "Location Content Transfer"
-msgstr ""
+msgstr "Transferencia de Contenido de Ubicación"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Location not allowed here."
-msgstr ""
+msgstr "La Ubicación no está permitida aquí."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Location {} empty"
-msgstr ""
+msgstr "Ubicación {} vacía"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Logs generated today"
-msgstr ""
+msgstr "Registros generados hoy"
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "Lot is not in the current transfer."
-msgstr ""
+msgstr "El Lote no está en la transferencia actual."
#. module: shopfloor
#: code:addons/shopfloor/services/delivery.py:0
#, python-format
msgid "Lot {} belongs to a picking without a valid state."
-msgstr ""
+msgstr "El Lote {} pertenece a un picking sin estado válido."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Lot {} is for another product."
-msgstr ""
+msgstr "El Lote {} es para otro producto."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Lot {} replaced by lot {}."
-msgstr ""
+msgstr "Lote {} reemplazado por lote {}."
#. module: shopfloor
#: code:addons/shopfloor/actions/inventory.py:0
#, python-format
msgid "Lot: "
-msgstr ""
+msgstr "Lote: "
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_shopfloor_menu
msgid "Menu displayed in the scanner application"
-msgstr ""
+msgstr "Menú mostrado en la aplicación de escaner"
#. module: shopfloor
#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_menu
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__menu_ids
#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_menu
msgid "Menus"
-msgstr ""
+msgstr "Menús"
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_shopfloor_profile__menu_ids
msgid "Menus visible for this profile"
-msgstr ""
+msgstr "Menús visibles para este perfil"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible
msgid "Move Create Is Possible"
-msgstr ""
+msgstr "Crear Movimiento es Posible"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids
msgid "Move Line"
-msgstr ""
+msgstr "Línea de Movimiento"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__move_line_count
#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__move_line_count
msgid "Move Line Count"
-msgstr ""
+msgstr "Cuenta de Línea de Movimiento"
#. module: shopfloor
#: code:addons/shopfloor/models/stock_move_line.py:0
#, python-format
msgid "Move lines processed have to share the same source location."
msgstr ""
+"Movimiento de líneas procesadas tienen que compartir la misma ubicación."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__name
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__name
msgid "Name"
-msgstr ""
+msgstr "Nombre"
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "Negative quantity not allowed."
-msgstr ""
+msgstr "Cantidad negativa no permitida."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "New move lines cannot be assigned: canceled."
-msgstr ""
+msgstr "Los nuevos movimientos de líneas no puede ser asignados: cancelados."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "No lines to process."
-msgstr ""
+msgstr "No hay líneas para procesar."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "No location found for this barcode."
-msgstr ""
+msgstr "No se encontró ubicación para este código de barras."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "No lot found among current transfers."
-msgstr ""
+msgstr "No se encontró lote perteneciente a transferencias actuales."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "No lot found for {}"
-msgstr ""
+msgstr "No se encontró lote para {}"
#. module: shopfloor
#: code:addons/shopfloor/services/cluster_picking.py:0
#, python-format
msgid "No more work to do, please create a new batch transfer"
-msgstr ""
+msgstr "No más trabajo por hacer, cree una nueva transferencia por lotes"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "No operation type found for this menu and profile."
-msgstr ""
+msgstr "No se ha encontrado ningún tipo de operación para este menú y perfil."
#. module: shopfloor
#: code:addons/shopfloor/services/service.py:0
#, python-format
msgid "No operation types configured on menu {} for warehouse {}."
msgstr ""
+"No hay tipos de operación configurados en el menú {} para el almacén {}."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "No package or lot found for barcode {}."
-msgstr ""
+msgstr "No hay paquete o lote encontrado para el código de barras {}."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "No pending operation for package %s."
-msgstr ""
+msgstr "No hay operación pendiente para el paquete %s."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "No product found among current transfers."
msgstr ""
+"No se ha encontrado ningún producto perteneciente a la transferencia actual."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "No putaway destination is available."
-msgstr ""
+msgstr "No hay ningún destino de almacenamiento disponible."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "No quantity has been processed, unable to complete the transfer."
msgstr ""
+"No se ha procesado ninguna cantidad, no se ha podido completar la "
+"transferencia."
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "No valid package to select."
-msgstr ""
+msgstr "No hay paquete válido para seleccionar."
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "Not a valid destination package"
-msgstr ""
+msgstr "No es un paquete de destino válido"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
@@ -599,6 +620,8 @@ msgid ""
"Not all lines have been processed with full quantity. Do you confirm partial"
" operation?"
msgstr ""
+"No todas las líneas se han procesado con la cantidad completa. ¿Confirma "
+"operación parcial?"
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
@@ -607,65 +630,68 @@ msgid ""
"Not allowed to pack more than the quantity, the value has been changed to "
"the maximum."
msgstr ""
+"No se permite empacar más de la cantidad, el valor se ha cambiado al máximo."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__picking_type_ids
msgid "Operation Types"
-msgstr ""
+msgstr "Tipos de Operación"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Operation already processed."
-msgstr ""
+msgstr "Operación ya procesada."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Operation's already running. Would you like to take it over?"
-msgstr ""
+msgstr "La operación ya está en marcha. ¿Le gustaría hacerse cargo?"
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "Package cancelled"
-msgstr ""
+msgstr "Paquete cancelado"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Package has been opened. You can move partial quantities."
-msgstr ""
+msgstr "El paquete ha sido abierto. Puede mover cantidades parciales."
#. module: shopfloor
#: code:addons/shopfloor/services/location_content_transfer.py:0
#, python-format
msgid "Package level has to be in draft"
-msgstr ""
+msgstr "El paquete tiene que estar en Borrador"
#. module: shopfloor
#: code:addons/shopfloor/models/stock_quant_package.py:0
#, python-format
msgid "Package name must be unique!"
-msgstr ""
+msgstr "¡El nombre del Paquete debe ser único!"
#. module: shopfloor
#: code:addons/shopfloor/services/delivery.py:0
#, python-format
msgid "Package {} belongs to a picking without a valid state."
-msgstr ""
+msgstr "El Paquete {} pertenece a una entrega sin un estado válido."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Package {} cannot be picked, already moved by transfer {}."
msgstr ""
+"El Paquete {} no puede ser seleccionado, ya ha sido movido por la "
+"transferencia {}."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Package {} cannot be used: {} "
-msgstr ""
+msgstr "El Paquete {} no puede ser usado: {} "
#. module: shopfloor
#: code:addons/shopfloor/models/stock_move_line.py:0
@@ -673,160 +699,164 @@ msgstr ""
msgid ""
"Package {} does not contain available product {}, cannot replace package."
msgstr ""
+"El Paquete {} no contiene un producto disponible {}, no se puede reemplazar "
+"el paquete."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Package {} has a different content."
-msgstr ""
+msgstr "El Paquete {} tiene diferente contenido."
#. module: shopfloor
#: code:addons/shopfloor/models/stock_move_line.py:0
#, python-format
msgid "Package {} has been partially picked in another location"
-msgstr ""
+msgstr "El Paquete {} ha sido parcialmente entregado en otra ubicación"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Package {} is already used."
-msgstr ""
+msgstr "El Paquete {} está usado."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Package {} is not available in transfer {}."
-msgstr ""
+msgstr "El Paquete {} no está disponible en la transferencia {}."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Package {} is not empty."
-msgstr ""
+msgstr "El Paquete {} no está vacío."
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "Package {} is not in the current transfer."
-msgstr ""
+msgstr "El Paquete {} no está en la transferencia actual."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Package {} replaced by package {}."
-msgstr ""
+msgstr "El Paquete {} está reemplazado por el paquete {}."
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_stock_quant_package
msgid "Packages"
-msgstr ""
+msgstr "Paquetes"
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "Packaging changed on package {}"
-msgstr ""
+msgstr "El Empaquetado cambió en el paquete {}"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_packing_info
msgid "Packing information"
-msgstr ""
+msgstr "Información del empaquetado"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view
msgid "Parameters"
-msgstr ""
+msgstr "Parámetros"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__params
msgid "Params"
-msgstr ""
+msgstr "Parámetros"
#. module: shopfloor
#: code:addons/shopfloor/actions/change_package_lot.py:0
#, python-format
msgid "Pick: stock issue on lot: {} found in {}"
-msgstr ""
+msgstr "Entrega: error de inventario en el lote: {} encontrado en {}"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count
msgid "Picking Count"
-msgstr ""
+msgstr "Cuenta de Entrega"
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_stock_picking_type
msgid "Picking Type"
-msgstr ""
+msgstr "Tipo de Entrega"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Picking has already been started in this location in transfer(s): {}"
msgstr ""
+"La Entrega ya ha sido iniciada en esta ubicación en la(s) transferencia(s): "
+"{}"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Picking type {} complete."
-msgstr ""
+msgstr "Tipo de Entrega {} completo."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids
msgid "Planned Move Line"
-msgstr ""
+msgstr "Línea de Movimiento Planificada"
#. module: shopfloor
#: code:addons/shopfloor/models/shopfloor_menu.py:0
#, python-format
msgid "Processing reserved quantities is not allowed for menu {}."
-msgstr ""
+msgstr "Procesar cantidades reservadas no está permitido para el menú {}."
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_stock_move_line
msgid "Product Moves (Stock Move Line)"
-msgstr ""
+msgstr "Movimientos de Producto (Stock Move Line)"
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "Product is not in the current transfer."
-msgstr ""
+msgstr "El Producto no está en la transferencia actual."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Product tracked by lot, please scan one."
-msgstr ""
+msgstr "Producto rastreado por lote, por favor escanée uno."
#. module: shopfloor
#: code:addons/shopfloor/services/delivery.py:0
#, python-format
msgid "Product {} belongs to a picking without a valid state."
-msgstr ""
+msgstr "Producto {} pertenece a una entrega sin estado válido."
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "Product(s) packed in {}"
-msgstr ""
+msgstr "Producto(s) empaquetado(s) en {}"
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "Product(s) processed as raw product(s)"
-msgstr ""
+msgstr "Producto(s) procesado(s) como producto(s) crudo(s)"
#. module: shopfloor
#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_profile
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__profile_ids
#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_profile
msgid "Profiles"
-msgstr ""
+msgstr "Perfiles"
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_stock_quant
msgid "Quants"
-msgstr ""
+msgstr "Cantidades"
#. module: shopfloor
#: code:addons/shopfloor/services/scan_anything.py:0
@@ -835,85 +865,88 @@ msgid ""
"Record not found.\n"
"We've tried with the following types: {}"
msgstr ""
+"Registro no encontrado.\n"
+"Hemos tratado con los siguientes tipos: {}"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Recovered previous session."
-msgstr ""
+msgstr "Sesión anterior recuperada."
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
#, python-format
msgid "Remaining raw product not packed, proceed anyway?"
msgstr ""
+"El producto crudo restante no está empaquetado, ¿continuar de todos modos?"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_method
msgid "Request Method"
-msgstr ""
+msgstr "Método del Request"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__request_url
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Request URL"
-msgstr ""
+msgstr "URL del Request"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_location__reserved_move_line_ids
#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__reserved_move_line_ids
msgid "Reserved Move Line"
-msgstr ""
+msgstr "Movimiento de Línea Reservado"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Restart the operation, someone has canceled it."
-msgstr ""
+msgstr "Reinicie la operación, alguien la ha cancelado."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__result
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view
msgid "Result"
-msgstr ""
+msgstr "Resultado"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree
msgid "SF Priority"
-msgstr ""
+msgstr "Prioridad del Taller"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree
msgid "SF checkout done"
-msgstr ""
+msgstr "Checkout del Taller Hecho"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree
msgid "SF unloaded"
-msgstr ""
+msgstr "Taller Descargado"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Scan the destination location"
-msgstr ""
+msgstr "Escanear la ubicación de destino"
#. module: shopfloor
#: code:addons/shopfloor/services/location_content_transfer.py:0
#: code:addons/shopfloor/services/location_content_transfer.py:0
#, python-format
msgid "Scan the package"
-msgstr ""
+msgstr "Escanear el paquete"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scenario
msgid "Scenario"
-msgstr ""
+msgstr "Escenario"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view
msgid "Scenario Options"
-msgstr ""
+msgstr "Opciones de Escenario"
#. module: shopfloor
#: code:addons/shopfloor/models/shopfloor_menu.py:0
@@ -924,35 +957,39 @@ msgid ""
"{}.\n"
"Please, adjust your configuration."
msgstr ""
+"El escenario `{}` requiere que se habilite 'Mover paquetes completos'.\n"
+"Estos tipos no satisfacen esta restricción:\n"
+"{}.\n"
+"Por favor, ajuste su configuración."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__sequence
msgid "Sequence"
-msgstr ""
+msgstr "Secuencia"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Several lots found in %s, please scan a lot."
-msgstr ""
+msgstr "Se han encontrado varios lotes en %s, escanee mucho."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Several operation types found for this menu and profile."
-msgstr ""
+msgstr "Se han encontrado varios tipos de operaciones para este menú y perfil."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Several packages found in %s, please scan a package."
-msgstr ""
+msgstr "Se han encontrado varios paquetes en %s, escanee un paquete."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Several products found in %s, please scan a product."
-msgstr ""
+msgstr "Se han encontrado varios productos en %s, escanee un producto."
#. module: shopfloor
#: code:addons/shopfloor/services/checkout.py:0
@@ -961,22 +998,24 @@ msgid ""
"Several transfers found, please scan a package or select a transfer "
"manually."
msgstr ""
+"Se han encontrado varias transferencias, escanee un paquete o seleccione una "
+"transferencia manualmente."
#. module: shopfloor
#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__severe
msgid "Severe"
-msgstr ""
+msgstr "Severo"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Severe errors"
-msgstr ""
+msgstr "Errores severos"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__severity
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Severity"
-msgstr ""
+msgstr "Severidad"
#. module: shopfloor
#: model:ir.ui.menu,name:shopfloor.menu_shopfloor_settings
@@ -984,68 +1023,68 @@ msgstr ""
#: model_terms:ir.ui.view,arch_db:shopfloor.view_location_form
#: model_terms:ir.ui.view,arch_db:shopfloor.view_picking_type_form
msgid "Shopfloor"
-msgstr ""
+msgstr "Taller"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done
msgid "Shopfloor Checkout Done"
-msgstr ""
+msgstr "Checkout del Taller Hecho"
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_shopfloor_log
msgid "Shopfloor Logging"
-msgstr ""
+msgstr "Registro del Taller"
#. module: shopfloor
#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_log
#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_log
msgid "Shopfloor Logs"
-msgstr ""
+msgstr "Registros del Taller"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids
msgid "Shopfloor Menus"
-msgstr ""
+msgstr "Menús del Taller"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence
msgid "Shopfloor Picking Sequence"
-msgstr ""
+msgstr "Secuencia del Picking del Taller"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed
#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_postponed
#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_postponed
msgid "Shopfloor Postponed"
-msgstr ""
+msgstr "Taller Pospuesto"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority
#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_priority
#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_priority
msgid "Shopfloor Priority"
-msgstr ""
+msgstr "Prioridad del Taller"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded
msgid "Shopfloor Unloaded"
-msgstr ""
+msgstr "Taller Descargado"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id
msgid "Shopfloor User"
-msgstr ""
+msgstr "Usuario del Taller"
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_shopfloor_profile
msgid "Shopfloor profile settings"
-msgstr ""
+msgstr "Ajustes del perfil de taller"
#. module: shopfloor
#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_single_pallet_transfer
#: model:stock.picking.type,name:shopfloor.picking_type_single_pallet_transfer_demo
msgid "Single Pallet Transfer"
-msgstr ""
+msgstr "Transferencia de un solo Palet"
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create
@@ -1054,46 +1093,50 @@ msgid ""
" move already exists. Any new move is created in the selected operation "
"type, so it can be active only when one type is selected."
msgstr ""
+"Algunos escenarios pueden crear movimientos cuando se escanea un producto o "
+"paquete cuando no existe ningún movimiento. Cualquier movimiento nuevo se "
+"crea en el tipo de operación seleccionado, por lo que solo puede estar "
+"activo cuando se selecciona un tipo."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_location__source_move_line_ids
msgid "Source Move Line"
-msgstr ""
+msgstr "Recurso del Movimiento de Línea"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id
msgid "Source Package"
-msgstr ""
+msgstr "Recurso del paquete"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__state
msgid "State"
-msgstr ""
+msgstr "Estado"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Status"
-msgstr ""
+msgstr "Estado"
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_stock_move
msgid "Stock Move"
-msgstr ""
+msgstr "Movimiento de Inventario"
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_stock_package_level
msgid "Stock Package Level"
-msgstr ""
+msgstr "Nivel de Paquete de Existencias"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id
msgid "Stock Picking"
-msgstr ""
+msgstr "Inventario de la Entrega"
#. module: shopfloor
#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__success
msgid "Success"
-msgstr ""
+msgstr "Satisfactorio"
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed
@@ -1103,33 +1146,35 @@ msgid ""
"Technical field. Indicates if the operation has been postponed in a barcode "
"scenario."
msgstr ""
+"Campo técnico. Indica si la operación se ha pospuesto en un escenario de "
+"código de barras."
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_stock_picking__move_line_count
#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__move_line_count
msgid "Technical field. Indicates number of move lines included."
-msgstr ""
+msgstr "Campo técnico. Indica el número de líneas de movimiento incluidas."
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count
msgid "Technical field. Indicates number of transfers included."
-msgstr ""
+msgstr "Campo técnico. Indica el número de transferencias incluidas."
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_stock_picking__total_weight
#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__total_weight
msgid "Technical field. Indicates total weight of transfers included."
-msgstr ""
+msgstr "Campo técnico. Indica el peso total de las transferencias incluidas."
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__planned_move_line_ids
msgid "Technical field. Move lines for which destination is this package."
-msgstr ""
+msgstr "Campo técnico. Mueva las líneas para qué destino es este paquete."
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__move_line_ids
msgid "Technical field. Move lines moving this package."
-msgstr ""
+msgstr "Campo técnico. Mueva líneas moviendo este paquete."
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority
@@ -1137,6 +1182,8 @@ msgstr ""
#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_priority
msgid "Technical field. Overrides operation priority in barcode scenario."
msgstr ""
+"Campo técnico. Anula la prioridad de operación en el escenario del código de "
+"barras."
#. module: shopfloor
#: code:addons/shopfloor/models/stock_move_line.py:0
@@ -1145,24 +1192,26 @@ msgid ""
"The backorder %s has been created."
msgstr ""
+"Se ha creado el pedido pendiente %s ."
#. module: shopfloor
#: code:addons/shopfloor/services/cluster_picking.py:0
#, python-format
msgid "The destination bin {} is not empty, please take another."
-msgstr ""
+msgstr "El contenedor de destino {} no está vacío, tome otro."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "The pack has been moved, you can scan a new pack."
-msgstr ""
+msgstr "El paquete se ha movido, puede escanear un paquete nuevo."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "The package %s doesn't exist"
-msgstr ""
+msgstr "El paquete %s no existe"
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence
@@ -1173,59 +1222,64 @@ msgid ""
" It is recommended to use an Export then an Import to populate this field "
"using a spreadsheet."
msgstr ""
+"La entrega realizada en los escenarios del Taller respetará este orden. La "
+"secuencia es un char, por lo que puede estar compuesta por campos como "
+"'corredor-rack-side-level'. Preste atención al relleno ('09' es antes de "
+"'19', '9' no). Se recomienda usar Exportar y luego Importar para completar "
+"este campo usando una hoja de cálculo."
#. module: shopfloor
#: code:addons/shopfloor/services/service.py:0
#, python-format
msgid "The record %s %s does not exist"
-msgstr ""
+msgstr "El registro %s %s no existe"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "The record you were working on does not exist anymore."
-msgstr ""
+msgstr "El registro en el que estaba trabajando ya no existe."
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_stock_move_line__picking_id
msgid "The stock operation where the packing has been made"
-msgstr ""
+msgstr "La operación de inventario donde se ha realizado el embalaje"
#. module: shopfloor
#: code:addons/shopfloor/services/cluster_picking.py:0
#, python-format
msgid "This batch cannot be selected."
-msgstr ""
+msgstr "Este lote no se puede seleccionar."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "This line has a package, please select the package instead."
-msgstr ""
+msgstr "Esta línea tiene un paquete, seleccione el paquete en su lugar."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "This line is not available in transfer {}."
-msgstr ""
+msgstr "Esta línea no está disponible en la transferencia {}."
#. module: shopfloor
#: code:addons/shopfloor/services/location_content_transfer.py:0
#, python-format
msgid "This location content can't be moved at once."
-msgstr ""
+msgstr "El contenido de esta ubicación no se puede mover a la vez."
#. module: shopfloor
#: code:addons/shopfloor/services/location_content_transfer.py:0
#, python-format
msgid "This location content can't be moved using this menu."
-msgstr ""
+msgstr "El contenido de esta ubicación no se puede mover usando este menú."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "This lot does not exist anymore."
-msgstr ""
+msgstr "Este lote ya no existe."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
@@ -1233,30 +1287,31 @@ msgstr ""
msgid ""
"This lot is part of a package with other products, please scan a package."
msgstr ""
+"Este lote es parte de un paquete con otros productos, escanee un paquete."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "This lot is part of multiple packages, please scan a package."
-msgstr ""
+msgstr "Este lote es parte de varios paquetes, escanee un paquete."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "This operation does not exist anymore."
-msgstr ""
+msgstr "Esta operación ya no existe."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "This package does not exist anymore."
-msgstr ""
+msgstr "Este paquete ya no existe."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "This product does not exist anymore."
-msgstr ""
+msgstr "Este producto ya no existe."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
@@ -1265,142 +1320,143 @@ msgid ""
"This product is part of a package with other products, please scan a "
"package."
msgstr ""
+"Este producto es parte de un paquete con otros productos, escanee un paquete."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "This product is part of multiple packages, please scan a package."
-msgstr ""
+msgstr "Este producto es parte de varios paquetes, escanee un paquete."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "This transfer does not exist or is not available anymore."
-msgstr ""
+msgstr "Esta transferencia no existe o ya no está disponible."
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Today"
-msgstr ""
+msgstr "Hoy"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__total_weight
#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__total_weight
msgid "Total Weight"
-msgstr ""
+msgstr "Peso Total"
#. module: shopfloor
#: model:ir.model,name:shopfloor.model_stock_picking
msgid "Transfer"
-msgstr ""
+msgstr "Transferencia"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Transfer {} complete"
-msgstr ""
+msgstr "Transferencia {} completa"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Transfer {} done"
-msgstr ""
+msgstr "Transferencia {} realizada"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Transfer {} is not available."
-msgstr ""
+msgstr "Transferencia {} no está disponible."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Units replaced by package {}."
-msgstr ""
+msgstr "Unidades reemplazadas por paquete {}."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "Unrecoverable error, please restart."
-msgstr ""
+msgstr "Error irrecuperable, por favor reinicie."
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unreserve_other_moves_is_possible
msgid "Unreserve Other Moves Is Possible"
-msgstr ""
+msgstr "Es posible Anular la Reserva de Otros Movimientos"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "User"
-msgstr ""
+msgstr "Usuario"
#. module: shopfloor
#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_ids
msgid "Visible for these profiles"
-msgstr ""
+msgstr "Visible para estos perfiles"
#. module: shopfloor
#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__warehouse_id
msgid "Warehouse"
-msgstr ""
+msgstr "Almacén"
#. module: shopfloor
#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__warning
msgid "Warning"
-msgstr ""
+msgstr "Advertencia"
#. module: shopfloor
#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view
msgid "Warning errors"
-msgstr ""
+msgstr "Errores de advertencia"
#. module: shopfloor
#: code:addons/shopfloor/services/cluster_picking.py:0
#, python-format
msgid "Wrong bin"
-msgstr ""
+msgstr "Compartimento incorrecto"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "You cannot move this using this menu."
-msgstr ""
+msgstr "No puede mover esto usando este menú."
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "You cannot place it here"
-msgstr ""
+msgstr "No puede colocarlo aquí"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "You cannot work on a package (%s) outside of locations: %s"
-msgstr ""
+msgstr "No puede trabajar en el paquete (%s) fuera de la ubicación: %s"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "You must not pick more than {} units."
-msgstr ""
+msgstr "No debe seleccionar más de {} unidades."
#. module: shopfloor
#: code:addons/shopfloor/actions/inventory.py:0
#, python-format
msgid "Zero check issue on location {}"
-msgstr ""
+msgstr "Error de verificación cero en la ubicación {}"
#. module: shopfloor
#: code:addons/shopfloor/actions/inventory.py:0
#, python-format
msgid "Zero check issue on location {} ({})"
-msgstr ""
+msgstr "Error de verificación cero en la ubicación {} ({})"
#. module: shopfloor
#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_zone_picking
#: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo
msgid "Zone Picking"
-msgstr ""
+msgstr "Zona de Entreda"
#. module: shopfloor
#: code:addons/shopfloor/actions/inventory.py:0
@@ -1409,9 +1465,11 @@ msgid ""
"{picking.name} stock correction in location {location.name} for "
"{product_desc}"
msgstr ""
+"{picking.name} corrección de inventario en la ubicación {location.name} para "
+"{product_desc}"
#. module: shopfloor
#: code:addons/shopfloor/actions/message.py:0
#, python-format
msgid "{} {} put in {}"
-msgstr ""
+msgstr "{} {} poner en {}"
From bae418b0cabdfdcca94c174e23cef19ee964eb39 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Tue, 15 Dec 2020 17:56:25 +0100
Subject: [PATCH 473/940] [FIX] shopfloor: cluster picking, remove unavailable
picking from validated batch
Unavailable transfers could be part of a batch (following a stock inventory
happening at the same time for instance), as such we have to remove such
transfers from the batch picking when this one is validated.
The transfers will be assigned to a new batch thereafter.
---
shopfloor/services/cluster_picking.py | 11 ++++++
.../tests/test_cluster_picking_unload.py | 39 +++++++++++++++++++
2 files changed, 50 insertions(+)
diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py
index e5db7fd007..1697e43ec7 100644
--- a/shopfloor/services/cluster_picking.py
+++ b/shopfloor/services/cluster_picking.py
@@ -958,6 +958,11 @@ def _unload_write_destination_on_lines(self, lines, location):
picking.action_done()
def _unload_end(self, batch, completion_info_popup=None):
+ """Try to close the batch if all transfers are done.
+
+ Returns to `start_line` transition if some lines could still be processed,
+ otherwise try to validate all the transfers of the batch.
+ """
if all(picking.state == "done" for picking in batch.picking_ids):
# do not use the 'done()' method because it does many things we
# don't care about
@@ -980,6 +985,12 @@ def _unload_end(self, batch, completion_info_popup=None):
# produce backorders)
batch.mapped("picking_ids").action_done()
batch.state = "done"
+ # Unassign not validated pickings from the batch, they will be
+ # processed in another batch automatically later on
+ pickings_not_done = batch.mapped("picking_ids").filtered(
+ lambda p: p.state != "done"
+ )
+ pickings_not_done.batch_id = False
return self._response_for_start(
message=self.msg_store.batch_transfer_complete(),
popup=completion_info_popup,
diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py
index b32dcf1938..d1b3de47e1 100644
--- a/shopfloor/tests/test_cluster_picking_unload.py
+++ b/shopfloor/tests/test_cluster_picking_unload.py
@@ -239,6 +239,45 @@ def test_set_destination_all_remaining_lines(self):
message={"body": "Batch Transfer line done", "message_type": "success"},
)
+ def test_set_destination_all_picking_unassigned(self):
+ """Set destination on lines for some transfers of the batch.
+
+ The remaining transfers stay as unavailable (confirmed) and are removed
+ from the batch when this one is validated.
+ The remaining transfers will be processed later in a new batch.
+ """
+ self.batch.picking_ids.do_unreserve()
+ location = self.one_line_picking.location_id
+ product = self.one_line_picking.move_lines.product_id
+ qty = self.one_line_picking.move_lines.product_uom_qty
+ self._update_qty_in_location(location, product, qty)
+ self.one_line_picking.action_assign()
+ # Prepare lines to process
+ lines = self.one_line_picking.move_line_ids
+ self._set_dest_package_and_done(lines, self.bin1)
+ lines.write({"location_dest_id": self.packing_location.id})
+
+ response = self.service.dispatch(
+ "set_destination_all",
+ params={
+ "picking_batch_id": self.batch.id,
+ "barcode": self.packing_location.barcode,
+ },
+ )
+ # The batch should be done with only one picking.
+ # The remaining picking has been removed from the current batch
+ self.assertRecordValues(self.one_line_picking, [{"state": "done"}])
+ self.assertRecordValues(self.two_lines_picking, [{"state": "confirmed"}])
+ self.assertRecordValues(self.batch, [{"state": "done"}])
+ self.assertEqual(self.one_line_picking.batch_id, self.batch)
+ self.assertFalse(self.two_lines_picking.batch_id)
+
+ self.assert_response(
+ response,
+ next_state="start",
+ message=self.service.msg_store.batch_transfer_complete(),
+ )
+
def test_set_destination_all_but_different_dest(self):
"""Endpoint was called but destinations are different"""
move_lines = self.move_lines
From 5b2abb3c12f67bca5073d03bff1394f59ec52a81 Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Wed, 13 Jan 2021 09:10:50 +0000
Subject: [PATCH 474/940] shopfloor 13.0.1.5.2
---
shopfloor/__manifest__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index a519a8cbc9..9ca78bd736 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "13.0.1.5.1",
+ "version": "13.0.1.5.2",
"development_status": "Alpha",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
From 89aaf21fc978b619616f17d29c6ae45851f61ef8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Fri, 27 Nov 2020 09:44:37 +0100
Subject: [PATCH 475/940] [FIX] shopfloor, checkout: ability to recover/restart
If the user starts to process a line, and for whatever reason he
stops there and restarts the scenario from the beginning, he should
still be able to find/recover the previous line.
For that purpose we are using the `shopfloor_user_id` field on move
lines (already used to manage currently processed lines in the Zone
picking and Location content transfer scenarios).
---
shopfloor/services/checkout.py | 9 ++++++--
shopfloor/tests/test_checkout_scan.py | 31 +++++++++++++++++++++++++++
2 files changed, 38 insertions(+), 2 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 7a156ec2d7..4556735223 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -296,13 +296,16 @@ def _select_lines(self, lines):
if line.shopfloor_checkout_done:
continue
line.qty_done = line.product_uom_qty
+ line.shopfloor_user_id = self.env.user
picking = lines.mapped("picking_id")
other_lines = picking.move_line_ids - lines
self._deselect_lines(other_lines)
def _deselect_lines(self, lines):
- lines.filtered(lambda l: not l.shopfloor_checkout_done).qty_done = 0
+ lines.filtered(lambda l: not l.shopfloor_checkout_done).write(
+ {"qty_done": 0, "shopfloor_user_id": False}
+ )
def scan_line(self, picking_id, barcode):
"""Scan move lines of the stock picking
@@ -600,7 +603,9 @@ def _switch_line_qty_done(self, picking, selected_lines, switch_lines):
@staticmethod
def _filter_lines_unpacked(move_line):
- return move_line.qty_done == 0 and not move_line.shopfloor_checkout_done
+ return (
+ move_line.qty_done == 0 or move_line.shopfloor_user_id
+ ) and not move_line.shopfloor_checkout_done
@staticmethod
def _filter_lines_to_pack(move_line):
diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py
index 1af8473da2..6cc8c5708c 100644
--- a/shopfloor/tests/test_checkout_scan.py
+++ b/shopfloor/tests/test_checkout_scan.py
@@ -128,3 +128,34 @@ def test_scan_document_error_location_several_pickings(self):
" or select a transfer manually.",
},
)
+
+ def test_scan_document_recover(self):
+ """If the user starts to process a line, and for whatever reason he
+ stops there and restarts the scenario from the beginning, he should
+ still be able to find the previous line.
+ """
+ picking = self._create_picking()
+ self._fill_stock_for_moves(picking.move_lines, in_package=True)
+ picking.action_assign()
+ package = picking.move_line_ids.package_id
+ # The user selects a line, then stops working in the middle of the process
+ response = self.service.dispatch(
+ "scan_document", params={"barcode": package.name}
+ )
+ data = response["data"]["select_line"]
+ self.assertEqual(data["picking"]["move_line_count"], 2)
+ self.assertEqual(len(data["picking"]["move_lines"]), 2)
+ self.assertFalse(picking.move_line_ids.shopfloor_user_id)
+ response = self.service.dispatch(
+ "select_line", params={"picking_id": picking.id, "package_id": package.id},
+ )
+ self.assertTrue(all(l.qty_done for l in picking.move_line_ids))
+ self.assertEqual(picking.move_line_ids.shopfloor_user_id, self.env.user)
+ # He restarts the scenario and try to select again the previous line
+ # to continue its job
+ response = self.service.dispatch(
+ "scan_document", params={"barcode": package.name}
+ )
+ data = response["data"]["select_line"]
+ self.assertEqual(data["picking"]["move_line_count"], 2)
+ self.assertEqual(len(data["picking"]["move_lines"]), 2) # Lines found
From c9eba21309abe9a131036f26f0e1b88cee42f0e4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Tue, 29 Sep 2020 16:36:41 +0200
Subject: [PATCH 476/940] [DOC] shopfloor: zone_picking sequence diagram
---
shopfloor/docs/oca_logo.png | Bin 0 -> 3297 bytes
shopfloor/docs/zone_picking_diag_seq.png | Bin 0 -> 171913 bytes
shopfloor/docs/zone_picking_diag_seq.txt | 84 +++++++++++++++++++++++
shopfloor/services/zone_picking.py | 7 +-
4 files changed, 89 insertions(+), 2 deletions(-)
create mode 100644 shopfloor/docs/oca_logo.png
create mode 100644 shopfloor/docs/zone_picking_diag_seq.png
create mode 100644 shopfloor/docs/zone_picking_diag_seq.txt
diff --git a/shopfloor/docs/oca_logo.png b/shopfloor/docs/oca_logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..84f216c2941a7fd6c70bdb3c951539fd80258409
GIT binary patch
literal 3297
zcmV<73?B1|P)%z00006VoOIv0RI60
z0RN!9r;`8x010qNS#tmY3ljhU3ljkVnw%H_000McNliruD}6aw7|dN=?803B&m
zSad^gZEa<4bN~PV002XBWnpw>WFU8GbZ8()Nlj2>E@cM*01QP*L_t(|+U=Wra8%VD
z$3JJ2@J2yuQ7fRfRVzAHE3LL_wGNt93y8LYxS`q#sJzZeZoZWe$rvF&bU!0CF-SAhHV
z{oT|>>I+1GSAoZY2}yzt08Ywl5wJDsdjqfvr~;k_dUT7lYxH^=p}z_o3;ZqRdK`Eg
z*aF0WHhujQJ=aCRb3m{EML{eKg%0~3tgZd8uVHeEz}vK-V?YzILw|17zcm2206l?8
zz((L>eeaoi&I>i5eT%ioa5zjj9PV`8ibT3JuW4DGuFWwF!r?G8YHHH1qbp807kUmj
zFKPF`2|NRA{N{jRue`Ts>w#XSJ!{H*WuKjS^eNju*}r|*mOURW@7JTxbxp0!Ym(-<
z1lR?XiD0saU~@HwU$Md{#Jh*Gri=Vt+bIviRA5rEj9qKsv_OyIK%!yMXd|&K4)b!er{>R?q;H(Q=
zZ#0Vu!T~4|CB&ULT8mn;Ex@X)L(ZH@*fbGBlsS&mUkGs(a2aq8aAL|W?*h*P58yb@
zBSc(D*#-~_g)j`mThlCyN-ap!vJxPS0LB0p0B59V_7~cuZvfT++u9&jC8cAUCQ7NI
z1JX|G$1X3~(Y$+*fLRH%B!s|moK6!vfI#5jput?=IL_Tdh=T#!a85dM2wV1kl#l5s
z1冭-I2>m`V&vBf!LWr1>(g~X;m1zW(xNcgOz;Q|uVjM6BI5iJ3-S5C{0=6nC
zWBG=$S?NB%YeQH0LnS+#ci$l3LqO`?-3bgd818%fZv%m#5aKQ1oTxukvSsf_ME#-M
zz%b1+pi)O}H|3end>x1iAzlPdGA+wjsT8#-&e-e#!v~y(5U*%|cxs;ecAS8J0)|;v
zS6Wm+qW%!$ZAn9Uu$Ke(0m~9V6i6okuK*QMf5;cG4I0W9dYUP{YmY>Tjli8<^!p=#
ze*jn1MIypiT6&lxR!U*HU|a)i2EN@ze)Dmh^>&_O
zYWtaqoFwJQWd~1n9A{BQV|CidMoEe3g7I<=yEI?k7b_{BJ&b34y&xXH5m=tX{cTAy
z%x3yu$8pYAN;PNmDH#_YXspp3#p(=TIKV+*pLY0908innY!h%O1B<(b5LYHNF`1N<
z1jAv05RDnYNbueZYyui}()lmVyEOyv+D>6@JU%iD7_EBVE&3T7f!DNCKbW*3Qt51j
zcnHYq;60ITC=XH5Xb|;>ZUlalVS(=fcgArZ_?cbZl49diG<
zZMkx7`5nMq58j7>zW{gcKXBmD+vm=0OVO~CfL{PpfFshBYg&|)y^@^yKq16u9u)*f
z3w;T2kCJlBVc~c%SXx$Ab~R9~%S2vPh#5-C`h@o9Z?gOg`+{$;N9`#FgafuQs~trD
zLMm)mt2T^^aOt`k319{Q4+0l^fU+)N8>6HC5CPjr2z01+;OVFZn5Cp_H_8i4li7Or
zS&;}*=MS~v@KHXWZ?(>W%97T;TuFIzBE&Ttgj@I954mB|~y
z0DkT1XB}sdQfgOWKExji0*1$qnHsQ-#n}vP!(|~Vo-h*g{T>+QG4!;6ZM;2y{(R=l
znS*Ir4+2+vXw(=b#Qh#MwQ)+ywRz9i;R8Pd2B#S^K}oqL-ze*HugEB18wG*Ua9N0e
zZ9K1|lyqQyPk;1#OmRmpkxHImoZtb*J|*SauxaK!XVUr>cnrC+kg)e`kLzo4&8|}^
z;Y)B2vAhyDfbXP_{M+sir>VSlk{jjR0Fx)OuvDK6`~>izJ!No$DVEA
zo}c8)^K-r&@;Z;%aiFQG81wH*bFD`qfOwX~SBwl6yt-ot4b3EkmI4IVRq~3U`qore
z7d&ZF;c`H+ii_LRhs2?wrDdYvg^qt*0u1-~d7FMuXPO2aR^})kedd)Hb!AdtX(^^<
z4bJ<%48LvmNL)r=zuvn-nJts%~p^C(pMHkJ>4u(HS#S_&;%R?m~?(!J+=YVY-
z(1Lo%W6Vg?vVNFnowCq#WZJ4{Na4w5(F4R8F8wn>H<(??m9_
zd;!T;%X-pdH^+dpTH~?JH*T2C<74mUiif9WGgoD}ykIQ{smOnvx)jcj{>7J%zYbfM`GfTCZ^{t1Nz`L(#-i_t6RFTwrXv{6I-LFiDt~1AoX6
z=sX6@13m%{=y2K5$#pNFzqSDLb1JXo3rJE_bOi7g@U;v>_X7{(o)~_v!59ks0JlxY
ziyUA{z&37Kd0CK&Pjtlni|XrXiN{aGZLi4`%kR*j{8gK?9=PZB=Vu5W0GgDP{Sr=J
zYe`fKHIrPP0$#v9m23r$2F}u;_s_ZBRR|zSz>EOa<2Fkdi`syv1GX`8mA{tj?W#_R
zRaI3Lre%!*R^T>n7n7DKDQ|Aqkh(h5%$b;$^#bsH3TTnas{rW%NY-Y6mHr?B+t{RI
z>W_=Go@QX{Iv
zbUn&CENpcTtWMFp^)zkD3eAik2-rqWJ)KDhKIZ~UaeJ%^Eu;KfJKTwh_RgIHorC<#KsIaSPa5d$ffNh+ITjzgQM_Nt}Q?%#>J`0S?6%4)bgr>ZF
z&@Ic?>nJPN%5RI_1TO)Tl#~_Oz|efB02RO=_5Fu(7jMDi+Z|%%W=xEAr(GLG*trZ&pR$pGrg!NT<@mpY7%ue*%hQmkU^W6Ykf!nOz
zOFMI)<{i_#n=}xQC@I$iEQv&zl0(CFawCIjS>?D55Z}eENcW`UPfN+q`694FN%*{svfAKYg1F+zxUWQUpT8XzmngG@|nZd+L%Ji;f$aNNGzetMC+fvo}C*j!9!
zn0l5THqAjoh;Qlex{v0)3-~}u`Bt%=v1wVubrhFqYucxIwVxB!)z>p~`gB63iJ=rh
z)9hI)#3&8oV@k^X`Sb+jMXRpLetNi~Jjj9{X(q8m_edw+00000NkvXXu0mjfVH^?c
literal 0
HcmV?d00001
diff --git a/shopfloor/docs/zone_picking_diag_seq.png b/shopfloor/docs/zone_picking_diag_seq.png
new file mode 100644
index 0000000000000000000000000000000000000000..2e5ea4d70fbda52febcab4b320cad8f911a77ad3
GIT binary patch
literal 171913
zcmdSBbyU@B)Gmq*(jp}wQnHcm29Xx&ZfWW6Qb0PSJ0zq*x)(}!O2?v+&PB(07jF0G
z{=VP_g^{djtfhq-bSz`6-Ua9>;I4n(@zWt3J}DL+(Y8r!z28iDSyz
z{W?PwMw&|ggs?MOUu}Hi8X{`*NLx4iuruH=d54>wXo5J{VA8NG6U*-Q0l9t`OX3Hm
z_~4qMN35G@UEKE@4gTV6YV2DG4Zep<6?vqwh6nrjI?C5k)bD|j-|C2aTk7+aDk9sg
z%m&(B1k`mcUtR&+?NWby}@3Qab-BP=$Q*oqZJcL0L`Z*jy
zk62|d6{MuNKVY*7aIh_Xv)Y*}pSqL~_ffEV=rOeAHUC#U&bm{(fE3YGC(|e
zM?OePaeW_~r8+4<`#eRv<4`Ao)kjWcYxpr@wu9h(SL1c*S78v{%47Aobxa496}(&
zc-Lj2jjx5VPt`3*vDGXqtgl`qA`9rEbWe&I-15wP%wHJFgxpVqxyNQj(f+F6=F=Bu
zl8(~~3x&wW^jV9uKs8~vz>D18(w8jaNLZXNWg`^Gk`02H;{U>7kLG{|pj1dlL@Jo?
z>$mFih=dHi>LkANN-ljG?F2IEt3Vyv_MZD$2JZ2=iDJS1u@{B<3jyC?#bef`4uQ67
zO1EOj3o{Lhb-MiH22y@}#gFmUIBL(6;yssqB`4e66Za_!TB9*@9<#W3aeR-ui3&}M
zw&nitQ22zea4~zU@YRV3H19=&W=6n;a=)D7DJ5mjC#_1K&x|x3Dx_ch<8Y`^&gpa=5`39!uw91tB1QNUzrc7nqkIWoPX$>qL
zOQ^i_M-Orhuss8P81aj0c8L}rvHKkKsHtr`uwO9c^jOF^CGud}wI2riUpC05Yq>Vy
zR`ZBrW$E5w3H-pz9R3xv;vRwelM{>Gp?WgREbRnx6YuPJXFk(+ZM3Jci|?i5lhQ2N
zT>Q#RZRMKj4oa=(*}tVqxfEtKgt*wOb8S3QXkPhT?aAF|)AfdL%0}f)Y+-+7hRGu}
zmfgT(^VbIwe?>wtSdyJFQ;ySKj3e=-1z$S8wu&8~{NB8U%9FuHXR71ri1GBO*}GT$
z%rTjQVcdK7U9~5}^psyv*s=DvTa{kD{M#09uM7!>H;QrWP-{$is=uUvUwxy-nDN5%
zwXBx*RgKE~uzl5c6h=|&b7y;%2p142cQ%q|Sc2P!_Nk;*#$!`NopItr=rxXSgCC}>
zL4&|bst!NTI{MMM&P$;LlUF;pj_KPL%n%P{BQFv8W^ieIxPPL%%v+2nK64L8daAJy
zXAnTqULDx!f{3nN!D78H}ctd>6rnda;o?BEeyLhY0}4$79wF*WU-<=VJa4pJovu+g+-`vOAGkW%
z7@mb~
zP#CwuWF${G2*;$Gz4261(dxU`J^t&{e(D!5^h2mZSf&1%%{K|}2k2-;29@sy(-ku+
z&8JfAM^)E@Mhn&RL%<~PIIP|p7%YDx5xm-HyxO=p=vVGc^}MQi0RhjO7B^JZ*K3R>
z*8aJ^`on|N>x;}tDX!C{$fxB@R>;?>|jC3?!>
zex#TLcFWoQwcjvp1ZwJqH*j3gy{=S9|$VRGhCxkdN*rOVUP7Y05H
zu50Hv&9w)$Vg9)%v214J45p(6(tM|xwY8Iza_X{W_TSHfUA2-f2GTaUT?}VN^}f+-
zui17>2yVOCPDjQIBae+KctqnXsH##``Y0$XE6d83xb!Bh4QHbh^VKWw%znq?H2E>@
zd{F6rY9NFhIcj6P*OyG}aWa3Blyr6H{#|N92CKOi@kl?rTX(bIM4TYY!fPDu#X_FAKZoPfEar1w-shhBc79~w~tx1+@l
zpZlgnqDwFyqwdTZ%tDDmv9H#CLnP#Bp)#p%TTlE0w5N9R*#rgFaDnja-)w$yK9t2Y
zx}tWC>C2V#yR|mWytD!dd9bA2wd}K@43ahjBVrDxfi+yNk)xrZp%PV97Xd*xIT?&t
z1F2DtTfaXDA#6lx#O*|>2okvVoULDVtNf91Zb3m3hj9dC0gvtMaO=DkSG!~V2`&e_
z#dOYUT>zE*m%G>GmOuW50tIDx?(>?xvxQ}4>+=$RHR>yiLVwiZnC{nM`2__m*R&I}
z?q_k=E?Y-iQ^LvlV+Di5UYM0l1=fouCMH73m^vX?|IBE2D4lL+qdP{=awo-Ic_#^a
zoV?c(S078+zrnHB(1=Fh>6mKWh;-R*=IBk6*P;ktj$rDtU#|L|V7b>mw6`M5v*{^+RE*kok)aZF*48rL4=)>*N=;mlx0>1GbE
zs%ouWE(9`OrQK*fF_Fk-{>XFwXzshWL<~)5PtW7VkGcEaoY>gd>seZ>-+FplgK+bo
zn8Aq7F$Pcx*bPGna7#P(v8gvZ|GgO^lEU_*M3W}V$-J>)D&+F!d0o`25Do$D6^-4=
zx}$PIKeRRKAop|FxmnF3ai#e?jaE0u^&fNec6szG%ggtUrBWFM6~^nNQ^BN`mzOKK
zGNof_k!yN#Ln-8E{1sjdpetgN|2WQifzA+Zyo@r{lIp{3_a?4$FRs
zK6a7QPZPgnw%#l#yw|Fkty-6tpAUm|NI!g-po9_ce6SMmkof^>UZMH^m>uf=!2wJj
zJP_$j_q7e_AAC5y9Sbzq{m67)G`qme)O%DgOJ^dZv!zyJ60IUh$YrK7JcPNy|Mq>3
zZF5@)Q|G(gPbBHSu#K^9a@;>L%k$Oj=j{@n(+O^5138YfVw)JV@!ZPB)I@3}Eaf6K
z3S`k&*Mb#y?xRJC&(ml_G!&ThR^G%f-Tre)4_4|wk;PCRo6nL(Hl4clJd*A7onAIM
zAJA!TG0`HJ@x9c@rDYrOpvpWiaiAGAT*Y8*97a+Vf7
z$_UD{TE_W<4>H2|aqKN
z_~BrOK4lhp{;_P~pkMWm7#cCM!kOQ?zjPi}PZk**x=oGCnv;`Tpi~z_r}-l0QTCg7
z(r!^P?Gzd+s&H`;GSQGB>TvOYLndus<|E|QX=^+W!SR{OK!4|(I2nGA8vZY_u<32~h7K;-f>8}U(6SFbals#GmA?2Y3rvl@O58=8bT
z9~ioSEKN&$7WY<0O>MGHnz+G<{tsS$PcB#LkqwdJoKLyRB5+Xiv|-E4+wb~>oV0Uk
z5XWl{pnc}Gu!~9d`gkipH@C!SQc*$417efa(9xxs@8t@`=hG;*oiIwxm8;}RkZGT6
zaCKZs6L1lr*1z>{B$Vw|J`VrkW
zn`D1~ZAN=he^o$NyMfV$X&|5+^}W+oKXl@vf`WovS;estK#t1F#GE$z{VBXP!(Rd}
zKE%g=e^_NU@o0aH5$d|rFynfZ^F)C+k4-T`stN*#XDY<-d
ztjl5EhpZCVi=I#;x{7Hro>go!XOxI+Is+@SX*EShw|H0Vlm4RSwK!PmmFiBS)6>#Q$ze8X
zy30$ujuBn={ZEfBnW1h>8q)a+@xW&*-C0i_GnNx{ZzvcvZ1zP3tT~8_+bjb0xNKWp
z`bj*is;rHX*Zv8Ccq(y=XQbi_`9dGQ-Y5vz6Y&RgblDX(*(5*4fC|1Cpn-83C*P7wzDh
zK6ksgBHMXWoqat%wH#>3Q0h5ly6+uttJN{lur8rGtE#Cv6G0*28-}NIHCzEKPQYaM
z6G)-nME33%Eb3Jj$qd%%UF;W9*^{hxKL^&B4GB4{USfA9v-Lt8hn6}^<^F{=x6H~s
z!^LuJ=j7)N$77CP?C^NjJnu`@Tf$jP%H!F=jGyltSGOfmcf+lV0x=xh?DTY9z<`m@
zrJpj|ECE)%kB-muZuc#=pf8%c(aG+h9*{@ej@>b|rE#*dvV(wT6;u_y^V;)dMtC-p
z9Yw;P@;Yu$FC=q&cRoOWst|8d94MP1x&v*5Dn)b(yX(%JOjczYI}+@l2k!WEpJ`6qENHSV)%=s>(OR7
zeu#;w>4-JE*|?0RChc)e^B^1?IXw%y;8zdqzuBwroRIHtlMCg8e7&Gr8<
ze!0Ay-2RrOr+N{?-P`
z$3{UJz?wc=gx{v@-?1P!$kGLf_4kMSJ1YOK#t`Ibw74&
zJfC6i=P9$_7zKf>aVbJ#yLO`po^Ni97M8vb;hEiAu4-^ScG#MP@n0YC_j9f$MS{c$
zp5eh(S60|)w{i*!%*Ma!pz|IMpnL3kU!R0s%djRnOd8{AyX_@A&Q5>;ty$;Lc^fl=
z(wuv1d!`O*H!KOgSYaO#=;uA2PQBbT!pFzY%F2?Y<`7VNY;kM9Bb-dHJ=A@(q`jzS
z<%xY1$TusEhgYVcS4Yww`;llwTp;CnrTr=90iwOX=k+<1i3&r;=;@nJ=dTn=nHL5V
zAD|J+Wc+L+uq;2@BY>W?p|Ve>nM^gfRz*KU$6s%m&dJYDWYlYeUSAz=6gO%SNw5|?
z=`>2VAI*x@IvY;CI!tlhc0OH>1v@lD#O=@#At^j4;8231Ie}49aov~9oh_Yu1rpF)
zf>S^Ki+K<&-Pd!o^N{8bhou|mypSy#V58u7Z2bJ%IHs>t2KMCha&ly1bGILle5O?^
zmysl@^p8|oA%-J~imJyE6aGT3^JH&vx#?AgR)b5YLL%68zLPocLiMVcRx`B7gGF0P687I`Ft9$
zDEZy;*GV?IYrO$jlqv2<6QwE{!_xfjKLBwwp&j^&o2MDP2kCWGl8X$F1(DA2KgvK4
z|B-3xCvZb%jf@Wk9Yppk$hdSU>d|8F-qn}yxUF(C^~e83=Q#D~@Lr$16sN)ZkKD>3
zZA{}^TYr(*HrcHAf&{V3WaPDB9ofdi)p2>7-F=VdwEH(KkIhToBlL>PA*$2S%|j_RH@X%N54oWDr-3#%+%UPM@JhN
z7%&_5RG+Qq3-)$WW@aeG@-03|#JKF)gKw1ch
zr@;j#*Q>6mSOFlG!t2U)xu2CoTX4|Nul*yfZK$2t(`|Q7fYnApIWPPG(AtAo5`)t?
z?!UAImC36!s~RZ=PW-fXsux3bntcm_XF?(F6%`f0o3v_d3}Wdtf5>3Mem0()DlUiw
z;n1Q8o59>Ev<}dL_1{N5{uO=@sJ;L?)5U)_aFYERmsSnr;`{siLg8%YlK}d!GjnOP
zQ(W>o6b!aFb&Hq1km)))J0}`FJ)NCT0VBZBYwDL{)HkF5Iz2rl&HlyWSjK?BFFU0@
zbfx8uAf{t<;W#l3zHfP5kPB4HM9gvHcN(uDyrP?^@2I)XvZ#t1juOPmqN1XvftJ?I
zpqX|;bpLPw++GD#9{wUAkGM1G?AwDRG?ED92o@)0s+)~~zy~pT^&bx5_=xtphLp*6
z5jiAF?d+ptWBm!N8($JF9!=?OK5KCLAvgwM|10pQYT9`<88H6GX_skG9}(h3=Q~Mz
zW5Yo)G6!e*^^tr~Rdm=K4^K=y&)2@}f;$YOb;3$T?f22ZN%-6_oxBx6Fk_8(Gf1J`to>?>8aS`_rx_+4cou
zQ|pTpgcA?+G3!g&HJo;kh509eO^{nsQJ@d%d6|R7EI&qP0?KSYZ)|Y%9;~%yJgwEW
zd^UOHm{D3P_rrF=Aht78COdP5u%w5+(I$Ck3%w7ukn1lwV~FOG_#pn#ierNVUeYalXiX_&LCWR
z9L|{ieVcHw%Z^={Rzj$^R&ivegjb@KwTz`PT<=TO=d2XS@3L{*PagnFzI+|=H>ab5lE34O
zee6lG=W_i)-&Bd7Uk_Rq5K!r6=H`WOOcdnh$(W?M*2v-3{MpEm{UTO;vQ%`O^@aDa
z6u0CLoR|XA2ppM0Be>P*=p;v|;47I4qKEv|{q7%Ym7simuaro@Y}5yYQX>`h!(~9^
zN{*iz%>JBwab8|62!Q$#$muD*GTG%u{ZHe6MA4)v5_Iok{7pjSjW>ez(-SSvOZ&a+
zs}F73^6J3t-`HccLo*%$KQ}f$qY@SZlcOOPM?Tp)=AhMyzowEdlWbQ7}0KXTsEdWd}7kMw#V2i
z{K%X5;wAax$B+F9yWvnbe3?)C5TtJx04BCJHYuK0r*KvA!*f`YeIinCGmQgHv!?$u
zm3~CbQDo|c5V?lO>2e+`U@6QC^-+6!ds51@k>R-o6)x46)Q|o%5FCKG;93l3Zt*;O
z@I<*@KW`B9PNZd|md;&Pnmh?dT`Ye`L0MPITK2CgQ7BA2*q@TZjm04;D=TQ1q
za}V(A>CcR?NvxLH_YTI={FmioC%_G9;gHBGD%$@3fH+G1&WBhn7jYausV^q0YfRbS
zg(ra!W)Gh$zd8dAqQIW_O6tL*1*&B0%Ix91BsX@zhTZ*Th^@&AEF=OpGYn23=&?9&
z6{g(BZ6E5qtrpJF#}9CgAQ$nsE##=_>Bl^IrA{aMJ(Ls`Dat}XDS+~Y@l@qj0;{P|
z!en}7P)~h5k89{S!N4dp4U@Qpdc9MLST)$v{QP|3gqiQ&55Q)-ZB>kQ5z+bU-6J*r
za18Z?rjQ-oFPG@~ld*@C~l9hG>8Y09_*t0)zQB!5=?J0SDsRKn)IOj
zz%G7xj?^4zT+;asu!U_Q1j1?p0^d6+Q)zB|`to9xKb6WhSc)cAK
z)liveQ7gX53v#OaD0p8%uq-DzJnbGdsv*G&+W832P7TPp;s%3mT*l3RD9)-zAzmNZ=bRdvc93=I;|B58oA7j
z$_7x=Q;_jdN8bJbG_E~Rb9D||K$}feSwe@hBtS@Q1-*|4A(H3qblwBCSj*vEQbi(#%;296)wGjTn;Swz9Ybt`T1xSgyKNs6L!tBe5-7O?igeo8bly+WdehO8V{0p
zI(UrItEbH+IBb^uAsUYnD|)Dy%QLEho5xn_cSa}`s&(=>>TAw-?9cOD9uBAb;@xPr
z?a@bYWXdy)^P^;{DAC=p8scT|IjJJIo}sq`lA5bc$i
zGQ!{3h^aSvT!L)yg?w{(MfJ0M#JjUji>Ni?!*!09sf5iV=HT#WSTh{a=PDQ^6aLTfgt|+s@QW$KLiyOB-W7Y
ztMlGQ%j~0BK*qn4LqVdf@Ahhfi}cU=z{;dNTUp3eKO&a~w!H>|zyWBaZlaLMDocKc
zO%+uMviNm3KG0gChn%DjlWYnJ)Tm301K=qtYf=C}f=u^;?Gj(%qnouvAz?lL>1q8V
zeNuqcNl`I++@p&syF;xBlNE2;x}6BXHexv}sMedN1YHw+&ex7i8RJBD%h79%ufi2p
zpaw=TJwnF(Y8kQgy>0mTDlwdXs5ZLrd<}26;s^w$LViI{PYfbXMi`$@Rwe3AX-6+
ztYtSS46meE)GWueKXon8ZuB@crGu>0}`|1
z#;dccS(p6s@^a&QlpTRhnv)>A285JBx3!*MtR%`=F$i)$E5LQ28ShER=T<#Cw~z1{
z?QPN->-;KV
zy)UQ0)oG$_ekM?Bw?@jfP&s80DGq<)y_NPC{hdO!SPrc-tSGR+|K$I-Vzo)YJ1|eU^^Q4*BP+#BeWZn-{H*<@M_*@Qu
z0y5?_pDg!#>LPaitSyMH_9rOxl)~$7e|`o{;mv1Ew9F^jbWs6Q#a6(dKo~-Sro#&d
zFW=cXhp_hD{UmAj1LA%TT7R7{ZXQ_kvmCrBD6rHtwLLMq-SOOE7i5Q9d+o&Zpuw{Y
z|33udfPWt9gFggH0eW3gRQ>$d7oOM0p4#>!iayky7CUr;(#aI^^Z;w=7HA)^ds}Em|+6+Z*4DodS_h584g9+Vty!%P4VuaOJL~_ydLDH*=U=!kz*QN8M0%YvcEm>vh
z-^w0h9`8;?qI+xjK)Dd#uf|boifF7zt8oXA(($65JTOfnDbT84*y3+UhhFy*&v4b(
zzg?dOQ9dFfqO`QMtEZgjy1(^RW~O?xyuOWtGLKKqbqg
z5oue;OTBE>NF$K0e*t7B$+c}n*r(ZTtjH4GWP@KD}S*A?%V!#wUf1bnj$}s
zpI|cG=N;RK4C4>5dzc>18}O5h`XNs~1`;Yi&nZI(>bxJ??K#es&c8_4fhnbi{A=59
zHR^NyAyj#G_E#;YXMEOg)x0&ZlcUcl;z%FhnDc;Uq&=Uw1gW`r+MnvkU3<5@FCaz)7x(m>w+Oa
zVy*mitRp5ZvrV0zo(3n3?imp}uS=dD{kGr6g)FMibT0K`6q)_Rr5`4OeHi9zmGC
zKaa9w%i4o&`N4QkMKY7I?*X{3Afdy@vuJAmHJ%FT5PZBNe}Gmn-hmG&_;_4*3|QLp
z3sw1PcXo9paM_z!wJ}~Od`zYHdAIU*iJXqqWa`H^L=u#6UjQG()ZSB-#zz0IjJq(t
z?d)eyCZ_yz%1B$w9uAt#j{pNs8#s*!An>&F;Ohyocn(ccVPL{I=A;X(Q)KFm>pW7=
zDSeOdc|fW#7p!-}Ji*uaw-;JLZ)_JwHnWZR4D-1iK^9a)X#6KkKNbX#+9a=c`j%wr
zeSwerUkKUyQcClo*c{J`11BUr(BGW*07#K1n?C+E(AV|m}7etg%ryVU6?u+7J_cH@1)ywm-u1W8_o
zNJv*_XFO2aFnR3#Cr?f<{mnk`bjWBn~vWsyf`FN){#`LQ&LOQ9vG4eVyWc=+%k
z5P{3AEU#NWs<3WM@uI|L>q{~3NQQ_thm)ZtRNKNgnFN!JeCTUm%bG!%@Iq)}b%J+z
zwP5BH(JgW>4l7GQMB@wd>bPL?n%PDz9ziz4U#zI!DlNN9;osQ$w6*|@6}*MrJ@ov#TV0F
zUPzNc|MwccPdRg%@p^0T#70JDe1W$X-o!H*45kB134f2Q3fg|vqcc)q8TQwLoH&EV
zs3BNPh#t$-heP;+_Q`0s`KGJPO?I0RF;ByB!jxNER|&ChUQczLi)z)p0v&OgnzVRd^ojBep?Jf6jsg*cPU+#)|+J9?qPn
zPg6vmgeHj{WT3&JdBO0Cv{zcDWtn9`E`T#(9^*)?iu<Eptpc{qE4W(8{n_OdAZ;{u{Hv(&c&`73i0wl*t~Gs>RT
z^%viC?4yzstEAT%*;KrfFI!&uMvorvRY6YbWfUtcjXIZ7RCO(P4LZC7%Vp`&^YU`!
zD0xa3U%G?eh4DAm+RT@BWkh59g?XF_cmUDx0PD*Uk!8g!ZmOjM}%9kI{zgAhLrz
zbzNUU;{LD;|Cyntxu&MMd1`y@T?tN2NDcC4cqLi;#n3?aN@Tg;IfxO~#3cI&9_t#I
z@-7Csu3snTXW11telRJaIw(OdH|8XovGHduq!ipWIs{SN($DjRQ;GPj#3cFFQIzQ}
z%)T|&*6`nQ{mlv!mLlIL4dT4K?rnu7z!x(kO&gTs@|2x-5;?
zV#9Os8rN-L(a7y!XRpvf>qk5uU3@IH!;L>#^RWTvz_!@Qcv}~9o10qB*C-lgD+xwB
zDj}HY@1_ka{)}AEwRM;v94FmxMU%~J=5m2mG0}(yVX^!0SyB3Crg5&lui28`^YS?F
zi=PTSt(^R%oT(f!FAKszl1@tSq2!AmLcB0a71wcOuH?L72U(saoaU)2MBtyFE0~*D
z!{>WDdI>&!u0AChb?5f~x^8{usdm7bnAmj2wLTc3Lzu7hC0Q%+xBsFX?6*me<@r#^
zuN*rU1i5f`x|om0M1MSrZ%QfnQaB^bT~>#(bG52Ga<}R#L-B=<$5FvsFU6&U(^NF~
zqW&nY#iVu2d5_wFg;!@6g+U1kBQzIR?~&jUJ3$eBb4J@aQ@4Y;q0mdIcXVt;pEds}
z6}9pXWBU=`ej7La;nZ&DlZHp^I?MElm*IJ;AGzMSYtHO?9*>#Y+RBVfilP(ea>z06
zXJJ`lFGLl@5=(0r(7k4roj2MT>8{cHqV6i{&{a@eOg~X#w0jMc`#dWkY%TaReqB<*
zT$RDY!K%AlAaVp}MD&Q1gdRG5k95dkDNdUUR(eVJANfc1mA@cnLk+os6l*K7!Bjl}uu0EiFtA2rWD|7=6xCQQsKQr0Htg;%u~oZ^sDAv
zsRwmWwh>>kBbC37X&VSnuoOC@&w}QZgYLX3Saam*cpR+EQo$jgJ;7axsQIdPzJu)
z;^s?-bE>RHN2w}t?v}GE@!hI7+wobI1?TUgVMBQnlKa3XSvlN@-xaq=s+{f|byjLg
zZAEU`z1Bg`o0US_qL#N*%iuCtcWv!{I~M$t<7ERNvsjqTC8g<)?m6_e`YG3>zbp`W
z<&y&@ZQgmRR-qNEsIz(0N||HwcVq4E_i8>PH5Uy%&61~2l$P^>lSaT(5{p+$es1`&
zeHHDsZSBKzy(e0Ygy+`1BU}}4d>!^l*wyHhi^P0}r$WTaEHz;BHZT~V&+iQ&qX12>
z%XiWa&bQYbfDBH9s*(Z`HzK30t*p*|7A=53|0NA&i>}YgdXZar+|eYvrpYZrC*R&B
z5NqCJ;tf3k3a@}!2|!bBM_5j7>;Iv(0cr3K9duGIKdABl+F+J5dLyEmdGH4lqny!#
z$FK^t2F@ne6p|~07S3=Eese@K8=NAZN?G2OYVAcY!L0B`%uo4rElvF@$#(pUzjb+B
zd!85?{vZhNrtk#cF(N7f!9$ae|zx~2WL`+3prKOfNO-{^bmGjx!0c{)A
z_r~*&&(D=nvmi3`fGsScrtU;XY3uG;`|p->V#6*i(;&+Ao98F$quo!@8^37fd5v~^
zQ#{yoEj%c}Wf$t`DKrhW7qwU+huMQLlXw5k>G$UEjYV&N-DT2
z^%V-kU?Y&FVbo!wgwSBt|
ztFb<7TcBcj_e$_cKN|}p+yb5ZpZc>mVO_=YQX_s9UbUqE1$S(b)X(bGTpeN0-#2*XI!I&mb~I6Mq(-?l+iqD$EG}8ggKUUNB<9#QoSn(om*j}Nd
zVL$6gbhfbAG~e_|`QJw0!zUYQ1EGmI)LpH!gqY?`zfD(`d}%qc?xoOVXc7ZGk;{Zz
z1SwoMiyb0bW0CS7c01V>@%9`;YSgl0ItQfqmfDK7xHNvT`r)~h`;675YJH`6X)}`l
zZw1PQiS962Y7*Hs0ny1nqnn(32do?lEj#*U<)8Pwb%iQ!Z1xBV7Y*wg$c1$?6M{;;*n7bFxG
z_ja~>I+3?%xl&j(b>Z^6VC^4sG(WX-i^U{?GHgcwq;JJJ2)iBMOl7MqTQZq)xw1PF
zq?|)P>kLW#>(M`a-5skN^sfjFIn5e>bfllubjE3y?Fu!hu$bjL3~>o{m)Nvq|Lifn
zV-6hjRy*4u*Vc#J+gZ<01@-`|MIzfps5;dmy&7YqKb$^^
zK~%b|DR1IzGRf?OY*D9J*2jLp(KW103qsJmYMn>l*2wy*LEWDn#g*brqGUOrf6e3K
zfC<)5!Q?YsA#Hx~HiDKKdF1N^tQGB?E`R@1tmXD?Ok-{v4aj5lp5KHUi~)5h;w=aZ
zLZ1yDH75|R(1G$Xl}ljIx5j+6WYM53~2)(<3;f{r=naN_F+K-)#7WLWvo^
zLwGkIXDSl_1@*9*yTaq~InIH2Z7$oAd=lbux1r0#+s%ne2@1G)gY7q5VF_V2+!yq!
z$?dhRJ}qoF@INM!_160AE-}8UbuM9xyD<;4M*|Nf@%by%se2^ENJ&j@E;?b@C@F4_
zNPbc1B2v&UJOl3Ik-ks1`FAWe>+m9N0e;2ac(2;?yw6|%ilw|V@%h6ViXryfC9M}2
z1hR0kjdXflzGCYz{J10-=Rl{oy`_4U!NOF=InQX;n6|bygGc4-tA&G*
z(IR^G$GNc}i@A6GZhSH;m7V2ka4^NwnU_*;sq16=^V0i^8ib`!F}Jf5|DqG>ZK|u<
zF32y`OTtA9_n3n8za5O1jTCZ9<1=QCyZ!tIthbiBZa
zf)+?3wKJG6Nq;)VV2=(rCA3nvI2=5V!dy>5bzS8CIG?P>ALtG!LQ^T9zcs+B(d|)yBV&J8@biPgSGgu
zWjkM~)n{}X^#H|%>nj*5TF9=qNq?nd7Da5$PEDjY6*A_YNX!QD_|h+eAn`@wzpdqCTOF2QcAY@CbwAQm6R26EzfKg_V`hWkDdst7z6Dl
zTW%7WuyDNd0`=_g|S1A!xqtxMm}|Jwz%VDKzet_0_iC}*?gnEZpZ-9{y5iFW+p&g
z3FpZDg7$diiQ4m}ilBs6i4uyb`?%hKUoa|wTkR;9K!ZQ46S0e3H3!Q$8?O+Qct*b+CH`Zdni+Ru2_r4}6!=CgY
z`F=s;hKJ;&rd^00pCZ6fjWT&+or=%j5t(e{d~6y{4Jbf$HJ6|m;q4O&^`5OwRQZ@I
zvimU{CZrJBNHF|P(A5qkT%CsgPOaP{nR-5q&W@+P-O{1|X_qH8#jH_(4{sJCT%F2~
zT9`av8_*IX21PuiUGY;n-g?%=^}6Fa8`ZUsE|Ii$Fapz}z?X3L$m;L1R8xfG>IQp_
zCy>*N2gugMqsu?1+#jvayh_$mUAyJ3ETnUJ&wCC!hbmG}$B!y1RrV2{;egUP;Z1%r
zcbHKj6zB9c64a!ga0dF1+3uA1XZ|W=C(U=l3)${BGY5riS1`Xz=%lrqe+8W?H0Abh
zA>f1UeD9{tJW4yf{O2r)Q9PxSX8{m5>PR1M^gq{k#HXul-`F3vT4GU$=hmQ{-t$W=
zUtSL?Hi8^w@RGi`R+}v&Jj36K_t;Qq{C{3|zOvln;A>HfEwGgS@Xv05vc%0)My`os
zheP`5TijJ*Gc1o8MrpM6`VKB#u9nA%+4<#^wA3}3FQAt&mSe`aZy;OhljlG2Sx4H0iySR6)urAKp)XFj?1w$2E-geI*7
z_)oI2vNR)x`;`wyF5>OKWpiP|WE$E9Rs8Q4;cNkD&TMQhrNvEW2e0X}pIH)c9(@|q
z%b;w3B-P)*2rsYa8agS7+jU5k4n}66saAxfyzxOPh8NrEn)Bhsc4%MZzL5E$J~~2PWx(Tre0JsW|mmlgx&;Ngkn&ra|upc{r|nj~4z|JKVR|D5EEAIh74jl8?{5u=wd
zIH^TA^H+00K691mR*U9ZXalG*WnR_op3nIB!BuNd
zWQkDb0VTYgY#ernujPpm*11EB-NtU9N0lofxwNM}7;?fDR&e4cR{E
zAo{aPQC&BD+_yO9@~t0)CYX{YShzWn2r)k(qqN4p$A&6?>&!E`dB$RIl;L_LU6VjoPNcjRZ~z!=K{hE6q
zvE?98D%?00Qoe+cc?~X>jFpMF!Th4rZzd2l4oaG!rx<{!%?l;~+f}@2O=X_@yThB9
z)ViQ)1Dcp9pEEP{!5PWn?4={mqb(GA!&T7wQSy6wE)|(-r&Jbv>DV@cpLYaV_CZTN$HAYZ{A@$O6oI
zOXO0UH;evi1u9+5ZwQn&L9=k$On0Y1Y_LYmXN9=)K8j6p@vjE@OwbJkBsPEb&qKZB
zZNZT|aA*Zwqt(5%^R7J*WyiIr*?6G!V{n8shdDsEXs`KWT0Eo8Bg$WWd%RxY+IY-`
zsv2LOJ*ja9je1hyRnl!9eE2ZmEq`qyU8?~cX<`~Kgu6(i9FhgOH90x&S_84c>G}jt
z+m#==@*~OoB{eyExny~Zf3IjpWAXIwp|oyR1n<;`#E(AHf-eYVVMH}N-4;_@D@6a!
z{hDO-U&JxvKL*T283c#Z0|2Mb+gqUZ56%F9i`VwetE;Q|-2D9&A1qI-ME?tHBop_Ts)I6Q)3=zbDWuv_DKjF+wjsjKy9+BNO#e>*
z1Nb~r;q%~0rQEt;Fq&+2zdBSjZSS+Z#ICCb&1jNQ4lL@$53x(nS?Ft@84N`4Qi?o(
zU*43tie-0qAynWa9-|M!H{O+=rB|M}JixIUUB){up>$&Sb`%!@;YiO48P-nk5`C!F
zlOYj7AwjST<7BQG(0-p<(iX4M^FAd5S?<0jlBcfzZ87RME_?!_&$jUt{4oU)5vKL}
z-q0Q5&KVV0KObG7a<;)rz}5W3^z-!z78%w{HJzaYB(B@m?P;%q@m?X!J+(Eo`P_)W
zulL72_m@n6epA8!>K${*iI8sp;5{nuA|HN6rdyfV&v{eR$S@pHUbOg=+(p=CGnd3jcE8$uEpWmGDKY1tq7F@_l#^eY0lcFS1;3uKPo&rnZq>62F{u?KS@RNiX
z{Kb&7JhKIswwaLsf*1py-H2(Q6=B>=l-fsEEgg41Da9Su8x&fDbC)6X&)C?ft?rm6
z-F9UEud74(`JYCkH4QoVh^sXoHlD+#A0j`uE%Kl6!CVq|i&ymDL$!hnP%5TL;2xul
zhsPDT5&}-=1qB6lcXjCpWn*ny{1*TO;Sbo_33R1V&r1itK`{O(-VZJYw*^(SIfEN3
zODikjOEj>-S#%tnn5d`@9BInwng4?6|7NFXM>!T-)fVf|x9iHv%D^?MgxJ_Ba3_6X
zL3d(d&8LolQ5W&nd)J=$&W~5OFM@x4M~~R;Un8H^K`$+SA4o(*i{IUbW$=~0?@ab+-k@Xe0^HIH7
z+P$RYsH?FUT;jz1w}Z(c{JLuX>28l{5;(dDPAks94;Xh6ZMRg5;p^=g{{0Xyko&ZW_1mQVgV9+Hg1|0rfaHuV|e#(9UGZq#_duu3kwnRanN=v>Qwl_#f=Oc|4Wv`!%e)c~nVC
zMH7-r88U??sR*S)85)ELp)%9moD2<0=AxpBBq75tl_66I$rRf>&*QfDv(BwSrMlnG
zGkl)+^S=K0{qAq=F4sP<^E!{?SjSrHIDP8S=~JGPS7s}&SiZb5?{VQF4=o7L<{W?H
z>^v#rt+wvVlOrHTyZgx&`Ws|0sh`v~UNBWno-+jnVn9uL)yd!)6ya(~VXkh!%9
z1^gi>%W!fa)cv@qImi=xKIO-s0E1Rtu+*l0`7RpaDK1^Q<8+YcDMTKx
z(s{h4xs@{`Cv6Xv_L`gCQe=@?48pA%*2ELc>g+9i8c9r2V$Z+EqQcMEX15i}kjHq^
zR9(PgLB6#V2OXohuZzwiP;%B%X(~mUPCYk+a)lHEG#S@+mM@Mk=egDebN_`sm=eK
z6U~5|(nBpQ%oFV{q!oDBh6-3TI9Sznm)K^0B5Y%Ig>&nO7A+#xX7=jG5Z%<>RhKO0
zPT{QXZ>E$&Xb9Rp1U+k_S|JqO^|l+=p|qZs%gtY!^ohstIYq=L2nQnh?VlZ==cc{y
z*~=uCUKUv&Vn#%#;O8o}2rs)1-5@M)$Yy(x48@hc@iT{sIeosHUp*-5`*e9Gwb@L`
zSCWWPjY0AOQ@o*25M1Sxt=;0MJy&GrI8$~Jkv0omA762so}!QVc6&Y_Z*Qq-t?#K3
zCclQUJh0H&j;d2*As(i81j&xpDY2=4jJ5a(ifgQO`_3y0E27_gGO=%{B*I1_?k$R1
zB_L4WS05UALZfu;xozPxZtXYrnrJ}p2!9Gjn+HX`FjKOAS%iIWTV&4&lMy&*2rVv%^QW+)?TBb@&P;AG`?4_I>R@YGSJ`cvD{B7DrVZE
zb5mKOl6*WhYXo0zI51k3FJGK}P-p(q)F)3o*Yc=g|D7$@PVFT$^u1tOLvUk?)4)Zn
zMBI^1`y2wYu$FyiZjkSZPuPeS{f&`h8L^Mmw;tQ7dXAOb0j%v{Eut5PgV-Y%P`Smy
z!ItBZ6nK^KDc-WV1p1fSs|qV5u;LJ`H{*No^hn+B5*fH#wr27dZyv_AdJnIuMm_?&
zA0=mIW-^`MdJ>`K4q6*5ni1sh@B$At@4QBW%V*B&m;a8$fmH%o2G-Ygp49H=;^cX~
zlrkUK!3F|jR8&-OaPU;}XP@UNuW8+wESiD+PITeZ=Io!UNu4WstOgeuJDJn{`gKuw
z78R6}53@8UcPeZ{6-xg5Wa+V{B8dvlAXW{>?Yl2;DH$JP<~o!>Wzjw$Og{Kh7Od$<
zZ&ec*E0#}p`XO(!7Py!vZtp6pG;f&5cfIwc!moR2rjbu;0%wrxFQFpi&4Do;KOeye
zSFb0F#h(0s5-Jgu7iz0hpHcr)V2wChmY_CgAb*#vW6kaJO%SNWG{^T7=K<*pTd~nJ
zsFTWvxzI3~n995>{{H^n-rl~x(i6hA8wc;%5uv|*`;{X##}a-?DVXkli#^!|c2`tZ
zZu6BCm6Ea*3AnpQIpVhAmRvtn&`+=b7#Tei5)w|GIu#om3(o4r3pWg~o!rrRgZp`^gLk8$;d$f``RGZvutDpoE%zXmyL0Exmo1gG
z<8ja@m54irneX0hEqF&h%EEon<^Rt-nJa<+^P60g`PV=Hok$X={;exTUR=X$mf_O&
zNCh|6H7zf@DGdAQT;Eyw;s=*CJ$4?eebxJY?B7#q&xaAOA3S&f4}>_V!pzT0ty-}o
zQN3vIe=A`^@}@w{$!I8My}Yy1a|vzcsM!~UZ>^;*V`m>bLNrU0y1Sf_n)vkUgUR=9
z9k?P;RhVtJC*~3F^+(tDv72aJ=Gi`S;pMlNGRC<|dc738vYG$!=G}^C<~#_vDZV>?
z)yr|CXs`ENJhLpta)o}*^B{e%#3nxnW#~>+KiHPEcS%TBTla+}1}XXb>k4@yGH;}k
z;})^s=Dg)?0TYTPUuBtu*-jxDFTai>zQ
zayN<*iL$DyWxAu0aYag}9UzF)1A^fQms$h9)QFrT1YkzsUzuvBtd2;O9iF;kNd@ElPZJ{qBW7V=xgiFLgCH7%*|C*(Kn>8)g
ztB9Ssx-v!a*6k%W|4tpZRB6X{Uj9)klA^*|ZzirkQBhI`eX4(o?rM{wS*9!YK0os2
z!nCY3xD5)%wj>2D+)~K2{nBo+@X`
z^f$DWy+-isfOxfv*W1f0zyqeHErRE{mvOoTinfNlxza9nDOP##rM`8}wMdGX7$myR
z^R_*Ar~;7_!Z1HrSn^(zfd3~orDsK2k6-ecDEV1#mzD{%%VfZVOH5RBo<-U!M!n-e
zD6KayFHbS6U)(}K!Onm1vTG;Li?$RuDT;!0YgMFmXRexQQB-SOcS%2d1N>5S_w3zE
z?d=Kk$cKk2%-TpYi2Z{wE`oKJTK06A16MVKZm_57@|ChAIeIG%8ERlJF{f$Ob1d%{
zB`GssRdu?zhXid+2DYA<1DAdWSGiWN>9R153VUYO&B7vj>Owb}z8X0W
ztr?Ay(4C@EU{_wD_2O9C%~cmq&v4?XxKzzRR+#Q96$$f+`}b>Mh7e!vXKUVlr%F(u
zXjz-gB2#)M3E{9xDXjDys3{!InG
zMcr!uVaMQ`y}8InX}cb}Sikq-U5Z2J$F8wsB4k${PG_Z18m}i5N>*py^u4+NP
zQ@1887XJ5KK+Wj&^Ml2=$a&tGy0-R1+OcVZ;|iwP_>AcL9AnVFm}BxSa-Eyc*%$4O
z@DHy#pUx`l9`#uDukYByLzYiW-WX@3mHCgCwJCQ`;!ZElyZ=v~&(sf-Z!cC9Vl!Cc
zq&E7D?@4VtVBirC!VcAMEOG)w>UH}>Sxk2as2K~>vxkhZbV2~PBL-)jAe+PBX&(-@S&Y_%BIq4RyUO7!W7Y@XwVS&!&Fxt$K3imdQAt{Z~ELZrI-O&w{BteeMJ(
z?ihFLn4#W_3mj(@-NIk&eCfq@>cs;0Gh6ijnJkwz@yuEkv#U-*TNa$mzVBJPPdvW5=k**fGDA*q>Obq=Zdv+n@mZFwL3;NtS*=U%5Pzuf*T#B+IxRqpE;WWTcoV~+ysXh-Nl@zmBkKSUC(k?RX9g3cD}BS
zPww3xcW233CtDv6P4g7vNf%)Gpzg*dBlP`vCQoYVz8|*LwdP7x)zGIp<|zLD)Wfhl`czz8TnNKM23>bwo%_!>h+X$8i#m6o
znVUslN3E8YR>6@j=$`AEno1yb7FvnFKeLXk`+RX5pOMR&%(N}iMd!_%_fRb~LoXao
zevOTd+r}U}KDn}n?Cw$WZWbrsN@v_x4SF6Cfg|tO@#6pv?gs_&&7OseCEQ8^s4~^3
z#n#93_0{m)RV>x-?A46-?%iu^YpbuXzuLR8sY&VU1$TS!P0t_tc9*POT0-@KbIzIZ
zY*)iaKD4nw1lp72GS~$L2Go*+l01D*1upph;>pyQRngDk
z*qA@czDqnbrs)MDk|>wILtkyyQP+QW+|m!M7wVG1
z?oyrU&)wXY+`Kq)*(%iv&DN*cj%V$#vpzjLOjNb;EoTK4A+b8crZfxLg`Fby!x(jK
zv2auFN1N$q0X?pDd9yq+fvXx-IdM2^PZvw_CT&%umaDysBaGDO#6U|cL&S%jxC6zVq^Xu}?)~JXGQBF$RJAteo7$&$TW+}$j
zFQQzdjpp$a(B9n_dRfqtyBc6GaZ&5)>Iw-7L8xAwkHT=}{I(aK_RPa)oRXt-2K<18
zymVqt7{^-UnZfpZjn{0XJWzo@K~`Z!|9H^Ng_q4GGa0T{7Vx|Aw<^B~Z!ssSQ_(B-
z#d_}b{J6}WvJXyqSypg4HRX2DG!QW{G<34O&QO{zESGWJYJZuT(|dB*joV7rB>v${
zJVlkW(it$MIE+-FS2?B#OR(D8SK9W(GdjG}o%s4=Y}=mYC|#=JwywA$4XyRZuthQo
zu>y{oQ5s7eUm-~s9w^$YL6_Q+HbX+K{Os8z{shZh@}Ba9dKW}iI6<#hlS#$*CI_ma
zF3teYcpD!WQq`e*XyJ`CTLb$55J_IB4)|487^5)sA(is=HP|~k8?ku1>
z)73BWay!ymi_&0C5`t}e7R4KNyl)x0*{_nv&eQb2lEpnP?v
zyHyD7&{^-dwai0~r+~8TJx)5juz=#;VLeqA!K){2Mu#|2>!Sys6DCIICzEd$jHc`pdjz9R>x}JRm>QvFeI#+&IdsK@JUj^
zr%#d?AV8bFlqa968E@AMZyOvc&*9w<^p|(`P07lfZQnDk;M=;4r)#;Tm6jUNoeNU<
z$J`i_9hI#0!s_mK3+F92wcECN#a4=@TlnM0kK^Lv!o!8V({HrNAGiMxQLn5+e9jpz
zee(&f=e}2LB%ZTB`86EnP!*M4WnKPjTqGqa;$qE?db0$1-tT7`l8O2t*)-mtLpFbt
znb1ql=Sj6gzy3H+<~})!2!1q4N6T@b6RubkvS}5X4zAtkA^GBS)}iLw+AU*%AbyQ$#(2&JM@|6dM&xVUsd4N|pu#+@mYS#CU0uAV
zajmtpm}$tVyyD`4Yz4tX;RWQ4da&$8a@Jkf6;{Uz5OKUG5e#Yn`(d(_J++9Uh`6vE
zK+Jwx&~R0X>8Or5q
zr@ISBxv&4a!W`rNy;Z)LeQ2UL@{_UQTKPvOf!N;^IlROC#J*jj&FWSa37&>h2gYrl
z_=nh2G%$2I@>071&1ecf>wb20USy`
z;$~=nWGR|dZjZax^F*Tc9nI{D#oN@a40fj_T>tFY_=gp<@rgJF;MsYO*a|Lxcu9IH
zB8rLv7;b`79w;T#Gl}na7VRelr>W8sUNdMzl{hj6fbDKHqL*>g7x=l&IfL`p4loWE
zhHe;W(&~@4_q(Do?VK1{WUkQhsYWx)3OCyt;zm<4PbC4DnLy!<#Lq3W8ne7KxUK`B
zu&sX_ndlAWJ0~E1)b!+Sp%92BSyu@CYeTwjf+M}!ag&jfRO2%%VUwwFpA_aKBVC51
z9ad;-W?B6J+1OWDUZvc6Gq^m1zt&)`jgKfXozxfdbeSWT=nX40^mjLk2*W@MK0zzJ
z2SWFLCeO{FZJ@i9@%j3BB%8ARg_MCp1SLGUB~9b1UYu;zGdXl1T!nt>-o1MUu(Nzc
znM<+@F2HcH4{4THZ}OXmyqP%BjoA}nS^GL$?13k`G>4EZAg5S!^z%#N`kmqHouW%z
z@~oaxV))=i)?q*gOwkHkFY+j8D?Vqf9@ozC?D=!OzUauO!(TUKq4Hub4E#Xn=VoCn
zTt4yNJ&5bXu`5$o2$81Mjy5ChVaN+ZU|BFBkL|7UruM?el*h{v=0wDCw`c#2=r6x4
z&09>+qGz^e^y?LxQ7cP2rDYCHe*f3oHNu-Ul7OgCPv
z0!rZ{c?5u*r()IpAisC%y5R-nM&PXgfP{2Y5mn7&xa>Q!0C5JxYXOM>oOgGvA%4e>
zZP)xs*XMAM?{kO?8BaP|4R?sJZoP333Nb3I&jK&I=iE0
zTeq`Pbm*T#OZ!^F75YEujN3f#561$#k^S~~`qIyllz~QMH$B_k?L_9BXqIffvg;du
z;}JxedRg)h5GtvGaE)*Hjl@8njLvYLUj9cs1O8fzLywNvP?em%|RxAK-
z6bqehvr6}*Ff2Qx__$Ijk5z}{Ji|i?D{`JEWU?@PT9O~sl-@YcZDUyNs>?JV0_8+P
zT4{&r>n1IT(G5S8#p}`9!_F5{`kLohGRjy}?hd|ccl^2dYG;ax%XS^Ly=VM(Rii+L
zM*Hg|8#<+8;ov9gkYDmY-`^c_@4E39UnX9y^{V-gm)o6Bn;m{lb<(mwga5;%lK<=Q
zs!$vBaib-8yvDN#
zPCzeAc;`%6`6WS!8J8AneX1cik9$8bBgEl_5Fa8FxslYM^
zPa|m8NzNSVq-{l19~pZaOlw;0ZC`uW<3&si$t<1c;$xx#!=Qn=fl0HyfFPm64k+f4
zcATVlI{G{jk&$AvqixE5N}TnNr~9vxl6d=a`Va{4pE@|r^A0snrL9D*ClK9deQL>*5PM=#?9qgz~NcqC_jb!bxkhS+_eP{8wK4NyK_Wj`89{d7O+ek4tts4^V;O&QB&
zY<0gwHma(sMn?4@H3fQ-RzlWOgiuDE8pX*Y4iS-n0=$7*rf3r-oShhWknmM(Qb<4C
zpz5>*N3vt&St{6H%`hgunBh=jT@9APijrLwGwiiJ7iCk=1TOqu83FA{bR=G?Dr^It
z-_f2Vfr&|AJlKe6W564_BVl0ZqzY1Ek8YdR`gFFiUPnjA#<3}-4K+0v649@7Qp0cY
z&8pfaRy?Dz545D21j%7^m5I%EVN~kSd9+WUDYVfW8J2S*x@~GAK#M4I<045I&g#L-
z)Emmy%_C#$NQLwJYROIvX$v%?4aAvpNXt2@-HVD!BF1l~_Vs&pdJ;IWgmwwz`Ox25
z>_CjNckcZP0c`;U^z!5dDMLV4-HnA}$fmtb<0VR*8QB7J-R4S|!+Rce7_0=Oi^70-^DYwZxhaXn7K3B1Ao`oZjDzlB&6`&dCjT{gNfQ
zUaxB#DIsqqI(3Nq+qI<_By8h
ztCta&s=Dzt>jam?ZQHiBhLEPzr=lnOUt45Kb4*?l)9}=a4n+Bq+ht+hQZ#+AqFHKrO_1HMl-iHPZT19Hoz}kwqshO{bZ%OpadLH-~Ta
zCI5g-0ac&o2cvNWJ&rZeE4?ZpDJ2z18Ni$)Nff=arlPXW?8u?60WXIdv?sBg(lH_F
zN5p8GE4QqSpKR+Jw!m~9S~&;WYHh5$?Ap5uO*7}B|Ax&uP1X$-vdzwxaC&S8mPNR6
zNb-_g=6KYnCc3rIvOVHE&^M)YeKGs0>2;u3y7USvsU(PnVjZc=2eijW-k)L;pX+x!3{v!z_HIDgOmM&bPYDaXd!by_`GA{%eD#!hOuD1#W|4B0r?kJMd*(9rCz!kl!${sm
zYJzq{adA=i2A!ETfk_30g@rjeIoo(>?H?Q!e`*aq(1)Ke-a(-9m~`QHQyzw)l6~-ZFaFFaq{IHtM6Oo_;O1tCz-XZL^PIc
z(+2myLK6@DM%0__SorHf@6h*pk)zJaT|Xcq(g?7JpK%2Z)B6y;hxoXuo`8CHT$#bz
z1)8rOArJ`|uE$ab?O!3f$D#;xusQ$h(h5<`9hFw@V{p}%m-VXcieohnq4MV32-FoB$T^$|Ax-0#I=m*W69$XBV
z`gWny<{2I%$4*&1>{t80rIq-9Ah*}7U64F?w|EHOefm5FR-Q?Vf7gf2{!q4g5f&zT
z(YbvH4+!>ssaf5Otr~1zfdlhSo4-+6zb*MUWRV;9ri|TL#LehQLIFw!k2p&7`ITmc
zu&(1QW%3qT)}AsMJGqfhws#BfHH_Oz)#;-owPNgNEa>8U_Ur+MavZFTP~UoxrB
zu(6i`1JqTji5Q2Q!pj$kzce@?Ad%3kOzFHbwiTe!VBmqD$l+|TO!$<1qHvZOR6UL?
zak?=X;WS;W)Y+r6t7{5Ey_fTe0kA!^f06B$JwrHyV-CP$19y-xro&fhdkHT6rrd)w5j?1jsCk5b$AP4|~?g>4JxS
z=R+NP!V@}OVS3{DaXBhtwU~A^*o_8+czf{#@GAjM!9CAv1kac1ijW8~M-aIGx@XpC
ziEN_YRq)tEC9cLcrewg92>li2CFKiuFCcl7r?CE}ARt%gnR$~Vzl{?;gsG@tS-Wdq
z^oEh0bwjyDB*WkWYYTsdk+^oBU;b-#kD1$*l$03IkftNnrQ%n>tj7Mp16&}xOv0QT60)x;0J>@0^~IOF(Bny4H}K#rj2
zl}JqaQ_IGl-9!b#m=r@=I0o$;=m;^u%#h-wb>sh2d#SEcD`eJZfD~M=W$%H>7tQ=hoK+%%4Se8)sZ~T3P6M*(x6j>H(q
z4~_vIb3^wSsf50d*`^S|IkE}u7(#^_s2PNNL~ILGgo!bI?v5|$iC>b%om;M`te^Zl
zI@lM!K_|y4WG?_A(e_&^mZOs?=$)<&CuDS9j~qT6jR8s+aWp+saFCJIE70$*uA_F@AwwLV`&eK_P`}yyPh&j|V?=DbEMHfUH+*(|k
zv_C~|K>?UlPp%t#K4%Z)(&%;ZaL|5d?8`h-*1n$bqH%eMAVZjTi<78nyL
z+ajiXf}QeskicS~ZsW6dN%?C1oOfM-!)o+Z`bV174|07b)SB(6E;sS?cc;(oe$VLr
zr?Bb^;AMcE8)w!gaEAt2KbIHQ+gMJKplL;9W#0F7qwDy(DQXV9>5hz>UYf-JXF%^;
z=jiY>F>$xOy`Rr;OFPhBvkthq%yD?3Z%O~>58&k2_9(foo%%D~=x4BISR|3Tf-Hn8G`QF-zvC1=
z{7;vAI816bEQt0?y7a$0M*wK~ca#vjX&qkL>ME6a7!k5r+XR1*y>_rGN@z_g^9a(_
zW{$qWccYi9<+{3B9`Ue^BhH<86B3JZ5C9h2@P{nc!RpVHsxJ@PxTe_6I{
zT(^@D&UI0+a
zUncJ1LjmvfcxZM&c!H<@Htm7L&%Xd%KcS~;0su>=c8iBbS9!U*y5>Gq(+@Aeogn$-
z4R!cC|LY;UbcW#tTOZXRC1YSdH8nMLbzr-wqVw8TCpPAJ|qVrnsnx=3;8MDEf3jhj@GMG1Vc|UnbT#^%+33
zufW$F7s3k4bm$5P8472;kI=kIE0a-Ap&N-fVqysm_>>)rc(9p5
zE48`ibz!%HJChGmNuVgCq#M?7`*$FAHrCc^AaxX3K|dZf;M+%Fh$_<;wfAbov#bMK
zf#6ZKvU+YFbWNGJK;|Ib3K?`(R=FqL;VgJn0$xO9HTN+Y&@|hn8T3IbHM8H7oec3E
z3iHp)$s}HMjlQI=v_>r
zxE!@e+N)Ks>a}!+&On`7blkwe1OTuXs>pC<7h4;ffL8RZeARP;itLGWDhikdkO9bho|a7Y(W;1`ffK}oXq4qBJq^D$@bnF0NZJQsZH
zGqGLE+1|#qC7t!QuWSE6O>)cZu;A7&QghX_~7;kR6=I9*y#E1I1vQ!yT>4g0eMzpfPe
z*rawcUr&S)$URVl-Ft!ih9`!y-2AxgL+O1}%0O3IS+!Y}k{K$E#F+9>PkW43v$3%O
z6uP~l!L*wp@Fuf=7)b0IBY|GgM2&@15}jNr{S2R)QVfFR`}q+2pz<9NyZloPACax&
zCfrxJ+zZsIBnx4qyhqW%P(e!Y^7bbBM#6cFG=dj{f8U27S6O-wsBZ10^v2S1>B^{1ARLj)
z;Hqy|yHJG2J|?TI6CuVGBukDqZCbA6ChR{Hy}5Pn>Oks0_8mmU_yVzG+4Yt6%_f_F
zc|_>f5IP%XNacg0NtM?!e>N;9E4I(&)lhPc%7zsBp?_s;e87tWEK|HQfskl^i9Mh!
z^?|iT9XjLI2{3BQ;fYY@$JV}GC
zH8PoZF~{9?AxQ;bfTFT1DDX_zQ|J|Vv0~Vh{g#c-@eR!2ojXWILUuoy8Jq79p8Xg;
zWys_gZWd)Wlx}@Rx|Z_2@MC_k_FHN>L`4!Yy<7bL80n0gLmvQ>+dOPM{?5B{v}ih;
zuW6AE!D*1{b0#WAe;efe^v_)KQGk#7k_3xCW@TkpOzOwHC!`5?A!%72cYm-X9W(u$ub+S=pLRWlukVeomUqs~9R
zV|>TdJLUfNw$Hc@08pQzM
zs$e&r^fRVLFvvtqzC=e4NAMszyHQQ(gU7r2s925F%tX
zZwHrDnaSz)g(O}KJ{69%B*!<%inzXjPEfr=^43m92o*h?fTM83`6i^C4ei;!{HBCbeFv&1dk^kO}GkGg?x
zUYgd}*sMlKd?Jh=!LYD%OZl&QvUJM-Q53W3ii!-LIW`lPzO@RVD9lHuAxj#R*Tt)p
zGl}A*h;ppj)*ngs4K^2L9z^fRuFIbv2)Q!bv}*TY=p=luHfZx4C1^G84c6H8v{uKSs)kxcrRo3P2FL{kT38gdzQk1q+7`-t6(u{Yg@+ne$|{+;eAgM11s
zk?S0>CX8oKGbYasu%R6wF$a3PKQJi7Ix886o0UH+^9aj7od_FWo4
zZ2;wYzRskk`5wUJ+SZUBBRy`2RH3G*#++e8I{yUa{L;{=vdzC@WmS~Rb-#V2WYxIl
z+d-$8p(~^;-MQ0*cNkRcYGEfNkYvUjYA7;6{=u$?iz;s%2j5LMhKp9&GnweV_vFaGhWpyjL;GjKy!WC4d1Zm5n
z5iXvfL+d5vLzRI;$Yd6~n5f3Mjcp9Sk#|zR5ym`J))B(k?SqvZ;hD5!nN1Z;QxF!z
zt!`|aBk>+MR0`;9$ym>GIWs@ntR4_bQkaOzmppdWmf5)B#_q`*Xw5i
zk^QdGLejG>Uj&@T{l!d}lq(erW=e)|MaWgmKl&-+e+yuLP+k7G(*NgVeg7#L_&H}9
zE@c=5Oj&+W{24O!dK+Fcg?gl$6ER4pQXs(9)F&t+7tJP3D#L9*p-=>QdaxKeHgxtQ
zr`<%WH)@@Y&(FEour#%$U}-Tiu2H-bww~_zRdDvR`r*MwH(_-%H=_D3#WZg?1|b22
z&J6^8|4IA%eoo_m5&!MF$ff@T2w6vM4k~i!0+57$C@ziz)T28tCv#}UGMp|mbZdwB
zY4+j4tqnIFe)-Nni|w?p*O-kF7@qMp_1+H!8L0=zLzuD8P1r)+{P`qy+0TW!5_;=@
zMYM#@;DeLInI*`cp=!Rny~$cH-DnBwWvLVOU3ZsFo1ni`iCNG9E+-e?c_%3vz#jMx
zrWo>>GVj|z@DyxT6?L`V$o$x=Yy$2E-(5I}bmfEM^&6yXTB!98*b`!#Lw8K6i(U!c
zNOdJ26#p>d*lrV6w3=XDND-2z|+|Q5T5qLTbp1x(7P*W=?L?
zQLpG3h`XE6UX}TVkh7i#1?eNH%o1m$S_Rg(fTP$1&JwEctPsrjz1=eItS};{zDPXi
zL;(jK8jW-=B?7(6)X6KYbprScz8{&~+j&;dZhsrPXFf+0NPF%e&Lbwgk(@gU>QGJ?XBFT(WG2ww+Ix|H
zWdn4mPGW)U=o2r^rBEVwT9wc|tzwaz8pL9*aavi->UNH#uBPHSjgpn_s^#hBNoe7@
zBm!;ODuRxtN1&O=HA=&Upn6r%gP;{4$O3dW(73h|R`9I{-voZacj-1h$}+vWB9s%2
zpR7pdmyqWNdH5@$_Mx-eN`vDTM`Ww{xk<@&8C65mGxB9Ux>4U@+7&n-&UdZ
zsgEm{aW1Rl*Lm9%7=i1s;gE*qVW`Kg?{8C9g55zfIB@%+h$aPt96%Xk0bK6
z2>_xm)9z`Nf055`R%0Iw53WdU_K%0Bf_|SPm;M!MclV76ncj{sX=Vr7;YsG2NCx#1
z!@r&|uV}5eZq_i0(w`#hCfuD+W6ZGvP6sO>*F^Ue-R&X(3L6zA)iMo*
zxP~QtU6rx*$tpdRRly{Ek-F%*nsz~Uc6L#bT?&r}82}uug#w5{M*IQ_bDJ$()$Y%*
zYpORUZyux<9@)7u06@R=K4IPkMc!iV>{7A|&Q>>?`g{Z(kS|bdB`Ou7ZIIkd4=GQg
z4J$KjqUSS}g`!GsIYUqMZy3}U2mxx)lXc#N_jN0&7enIXqN61oUM`Nf3eX8td2K=b
zIyHJCtcRbOSH9C7homt0nJ!Zam;vy}&(DvvcOICcPYg*ZvTWF`RcEej0Uidw7eS4u
zx@g*iUuT1v$yecZ$#N_E3&w^k%_0`N{0(NiH_${Mk57@SNa++G+Nj`nJPbfv#dz?X
z7TG7QNW!WY6llq9{9M!B0TTKtm19gkMQJI
zzV&|5-Lz}7NW|3DF;`A5nH80M{?x@7vFW0Q)04(&$nMYYkH2zZ)`qR|6;H=Gy}OVQ
z>sxDO-q5~ka`nT0pVH8K&*=lrO^)53)n#GXnSO`o%P{R*t_~VBykI+ZZh`w^$o;$^
z_ZvxG$&svUhzgngt-XGi?53dL`1woL8JuDo>Vx%}%git<4}
zh7@G;Mok|)v-k}a#&Mogxk?;EmO5##|K>e(L7C|ovUrdw*L3FA{&74hAe!6@sNwnU
zgfV0xcZ{f4`d@UZpP$LF_s=Z#{?NcS5b@N`DiGRf_sXyC5wnOB8J{`xVd|WVU+>di
zk9a7fzZe+aI@f}u<)-qaN`F6Di)U?W$^Ij*hGquH8~?H%?MK31R4Mt+_6>MGxF$O#
zg^5%PRV>8m{4W;y9c=oCtNr3_A!Wf}PNRP!)LztV7yjl7@^$^!7!!f;mu1BzxWRaW
zs}m&s`)KIs5unW2*cfOJoIl$z&YhFX0^%&3t#D8@0|g1{TSA>$nbW>bb)|r1f!(VK
zFk>J|2x{hV9D7BoPLE4fD{jcx6_g5Il?5%V7j!Pr!EL$<^ku;^D+_rEJH=CRaFCiK
zVP1ixocR8T8`x3;t;=S0&9b29!M?$B2Zw6`{+C~*uK&cB8*Y(h&B@vNsbjI5IIpjA^Wbv>DTuV
zOvBYyCQFCC>3eI!&v+3nT-HOVJ&~rR0J2Y;HE~np;GYEFi(^=ZL#a1L$B;9N#@?o1
z1EaNn1qQR$ZxOnloCa+F
zkdP;=q0W1QAjk!zbY%|3k%6-(@?m{99~b&o`{xK6!Q-_o-M*zvwQzB^8_wcG55I_2Z?N*yuFS4C#J7O)F`)e|H;P>cQgI^R&w
z^18M~hL412Nw=Av@+1=}#uM5$a>m}0u--b3<$5H(AbiY{7rE)`gT@TUg{$BUggt)T
z7;tIDrzo?7C-ULRfJS=kf(_?<%-`{Q=r~w+Wfrd3^UR>9rOfPw4W3*R#P-I<(!}cO
zk@_@=yB`O4{oBMV>3GDW#a05B#FiO*siJ9VTosu8#GC3_KiIG!QOA|3H4x>ozg6
zJ}0Xx_!qG3EI}{zV_x3O_4Wa$_-NT-m1|$sxJW0ukm(AZgaf$MNwLcmf
zU4J~}Qqv|e6pc1^%s*-P;80fJsBgOOR;CxyWnDiP!tAkJ|9<6}tGcjNQr+G${k!v(
z9n)56@nGU{oNv_R4NJD%%l4E&a~25vM*WAM;S~VuF_U96Z)I=gkT%@(%`|J+e>&Z>
z7ZCVl&>^_k#1b4lUCwRK^q*gN0;HF0*8FmhORsn$$9n;OZb0}h
z!WSf-8(A7BotAFz2e8p6_EXWQs%(bm$n8QEZ86Xhp5va~rH4r8|Fsu*S{eE$+z$gQPgVA@(p
z#aZ+**4jecp)n#KuP~b{%-e`pTEqS;=?F;BEAh`I1dlWaRbg}W`BA&Uu4rOXa@Pj_
zxV3OF0Z%86ItA&tV?|eVSkAV;>cRIbc)idYdM`=%^odUy^Gc805My^^lo{1rpJi#t
z#;m{_-`vXH3|ePh1$>xabYD17^}~&CXEfg6XqbRER6%cohGO6OV(w&JnU2!(WW9^c
zaOl3JjTD|z_u%2eYb@7y1=MU;_$BrRC~v;M?0fKCD6AHu*{ko^rC*Q^cOhjjkyx<2fwUBk9xDLDP#
z4{Z{$amd0wcSn>pj(|dw7$P2wi0H&G8pv{Un33VpWJV5iG|_`PCX6Yd?42V)blT{j
zIime#&Nt%sOZENrTk7jcOon6_y_xd*59dd{L;EK%oaOrNVN(&LfSXkb>ubK0B$f=VnA>1kc@23YAS=5ogJP~{}Bw7``
z@8IQw5$ur$rIq!bG8U$0DYkRCa8zq_O
z>Wh2<@+0r~4Eyhyo_b%O1NXbMn5Meko+Mjvp)8Fv*pxq1jpB6s`#&5lB;0M}`}pxE
zpS6qlRXT#l4s)L8R=T|T8j5y!TIXv?`_ptYx9}ad_-At9&aA+5!<Ul16H_@?
z?pqrrP&6rt2Tm?y&L8S`hZraAOXay{+1%>Pja^kHGVN)?#K{@;12#ZH?-5xc^WD1l
z?%w4g_a7Y8&2#USc#lf&`AXA&M0XFHaIE1OTI??rMvc1az?%yVwmc(2&_)t_6E=46
z|Alz?bqW$FJ((WA0~^yEN?`3M^p=vOabUo2;b2vnTvC*tqa?L_mTb@G(o>o8xggq6}l@sTV
zcWQg^Fys^1icQ5apqARc8qIMg2G#J)1{<)6pI(~L5!Y-ls7kX(6(@Lt6meQBE$0@@
z6*b(sk`5=B7oc{vM0gcw-Ocxb(b&Cid!GWf(}fwb%N*H)!-mbztN8aHw_arS<b
zu4nS`$%1PsYub_*6?no$jF{SiNPEP{h|rH#n-twFjsQYO#B<4)7l3Q|4%|||f%fMZ
zkVTBQeL_mWa>izY{W0c0JR&AZO8n0q!uGzSn1rF=gBjGT$&==aAH}|VwJWz#p81x1ELrTaTnsjftC!}C0ZT9vww+p
zT;q5Bl1ZZ;5d{O8SfE=jR(wTQd6lyDeqH(GN1vM?f7YF>8@jh)j8nQ=ojQzpL_Uy&GNg7$yxd4OU2x~ucMuaR#_s9G8L
zmc!O)>1`kGX@Af6E~Y_DCjgR66Zw5d%Kq~Ef?w@2JbLj=^1vNb5R6m`E!D@@QDxW{
z+N<*Zwz%MvU$cklo)RY-J`!qeK{}}44IsSeV$ux43?Qr5DY)n6di*V<{4nsehJnPH
zYp4OTiOTPqlH`3L1{2{10fd-Tap5pC9M8s!Bgp~6gT6{ZM-3bL@5)IES}%-B3e98R
z07^!C*m{lLD_)R-QyJbCqarQg?l|gngJo5DsWq>>mAd~Wj{c3&PV-ccrqgVbceQYI
zUJtda42fxa#}$60;AyBs=R@f@h0*?&MNwvi#|%9RH%&-kS+;5o$8|^Hw;3+8@1N>s
zlMpXTqZ~76E{TKg+jgKc-$I{IyV7PjA6CxjD_G|flYX7yUCTc`-fv-!cqQ2hx&>VPX_RiMSw{LSe`_lxvUd~{5gGg+MO#3721`-CZY??&&zwW
z6s*$~wp7XJ`xm=))U|?i&0){DM{Y^VLt`$FQ25=um5BZsCJr4SnR__)dYm51ik*9@
zKyWiLE&)>0M;b^*`5{lv&J@Q{Ps}t4CJB)?tTG*Kp|dy(4qKIzKnR59^>}GI(Pt)B
zWrEU^x%7nMMN!7lw)5;XIl>-Km?%htFm|f*X~rDHK);gX38mWeXAu=A&&*o>wo6V$
zieVN9H$C_~Fr(j>L-S5hWKcgVqlL8~9jq!sd6Zstz-!){=t9lWj>0?%YAcJP)l%Z_
zB2E(v{epBLFQYgn5R=gid6~8=sdzI{U5^)&ZTHMPbH1~8%OY=i+lSE$Ev=r7S82H6
zG%&{X#DHH({`j%%%VInq4qLK!w)Tu?5nqi;|6QY*L&HJt82J1UYMajn8+wl$BkFU0
z>{HU=z)*m*RP1)F{L#6Od>Yrz@NWJYhamL3ZQiTTQa{&SaoDm%N&>zdMW@4c8YMqT
zWftdFm%o+S9YbU0rx?g+SM(35n>C4EQtJ9mg=lW}UzGO0uAn0O<{VZ)fuN{JdO?
z31;NVCEbT6YJKg%xkD{ifO(W|a^;_u(=b1yxN2#SH~+NwQ)L-(Tbq_wme@VPs%9LW
z>$CUBea+FbhjrTxOhwfyrns#h;Z(dMtn_78NNJ93H8eg21SLE=Lq2N&BXbi|3nAFTKEY&x4uWqq2%!=@ELbvj;c
z96(M%pUgOel^1-t-YIRGGG%ppb+~I+2H7}0)33?L=!G1h1ivJEYG0L9X|K`=-jB@H{-9bq|cg;RgMCyV?Wb_2O
z-hp$1jJhi+P(f8>dXzM@U)!|Wt!ww9Yte(-V0!dv^StKTfxQu6b*3^2T8mZwOC^cmNqR=3)A_)=#^#Y
z1Fn^&m9dhh-;o%}D3z{A4$UPogI{`~m}*1O%_~c-WKWyNZ;HhxI}n!KNr;F!{`6xj
zi6wPsGlSk-rWF4<>u>NaQbt_}NK#kyWT#)0@A
zVUWSD6QuZ_|L=q~Cq}Tmi^MmQ?GKktHPHse0hePqpeJ#j%ccg0je$5pH({SwLqKPH
zo-%-7$a3j8oA=Q%D(yg6yqteK7$yEt_mh(s=ukfy2?Rtkp*PfVpx&`B$kEIG^ub!L
zGy*;e&ie{J9i<01fdN}quzRbr^V>`I0n@M9hs=&XkN12#F;jxe9r0Y=NBex5)eoXH
zcvCAWH=lh${PQTy)4S9gb5CRhXzKlJbWV&`SC>G|JV
zEQIomqyuBD_>1uH9IfCN69GaxECubrFL<4TzDx13oNaKO?1P2!3#-=hqiuxm3Ak_U
zq**I5qK}|v>ZJjhy#U3=23a0EhENIPrD(#KNa)4#BBTjmG+H*W#Z#AVG`%8K;5R_+
zi1kiO2b|mBqg?D2(>yYCXT8$-0J265ipPdkNc{SNc^2i^-bT#IuHOO6{!m6=
zCd(uK0XaNLm1Ts_hc%AHJV26G9n`X0#^4GHXodShP0k?
zN(&s`vvl7#ykNDrSEuLvg$tsChH3Ub-MB{J@V*~DeWt_HVEy?NLC)et(J0_-}I=zBeY=SX{OA-ahT4Z!{f8GVNRAfvK(C^F;}gz6E)A
zHfX)%uMCt(!oB+o6lU=wRGlkE@JP;wrdf*=eo5&GDR(IBbwA$oeiC|L_LsOcegQ8B
ziLqbG&%a&v{)r)xN0o!aCQpi_VmWMT*}aYNytSga
z^qNN4k@v*{5Ar`Ze9?$|5u|HpA~oiOc1z0!$7gjRX-r6{+vEE&aVo8-uy~+&o1im=p`+CM
z%B!qYXWnzJu_1ft;l5ITgbux=j_VzB`ZS$M3sE)EqaWxt(%Hj!Grs%LTul4v3EX7~
zc+Vxt(Ut8l%_$m?(#PSYPKQQ5llV9Bs!m7n#Nn13im7jhn_f>u#MQEKl~b7Q^G*I?-u5($#xIz~CePbGlE)1(730|IuLKKPPCK}<^a;Jf
zbFT{tv(48U4hXG^KJgV(Si