From 4daadf3891a17c83bdf14f6e6a41fd83dc8bf030 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 15:58:16 +0100 Subject: [PATCH 001/710] 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 00000000000..e69de29bb2d diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py new file mode 100644 index 00000000000..e69de29bb2d From c4dc126840dc8fdfc877ae3dc2354c6d788d085d Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 16:08:05 +0100 Subject: [PATCH 002/710] 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 e69de29bb2d..0650744f6bc 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 e69de29bb2d..5cc45996a68 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 00000000000..8442c179fd5 --- /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 00000000000..7a55e5ba991 --- /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 00000000000..756a0db67d6 --- /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 00000000000..dc27ee26059 --- /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 00000000000..756a0db67d6 --- /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 00000000000..098af7f8dfd --- /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 fd93852e25b11c7e8d8e49b9dc662471673c5add Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 16:37:49 +0100 Subject: [PATCH 003/710] 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 5cc45996a68..c0d07303fe0 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 00000000000..12a7e529b67 --- /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 00000000000..1a5fe43b700 --- /dev/null +++ b/shopfloor/controllers/main.py @@ -0,0 +1,3 @@ +from odoo import http + + From 9b0fdfb8dbd1e642e1fe727366a95e4c9cacc374 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:11:52 +0100 Subject: [PATCH 004/710] 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 c0d07303fe0..a6b85e88b15 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 e69de29bb2d..d8addf9144d 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 00000000000..fe7db83dd45 --- /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 00000000000..9e3ebb6b16b --- /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 00000000000..1e823b4e256 --- /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 00000000000..5ca90ecc73f --- /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 00000000000..c4a8cbb2f31 --- /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 00000000000..2bf16a31f61 --- /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 00000000000..1aedf5f4a0a --- /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 00000000000..9c50eafa7e7 --- /dev/null +++ b/shopfloor/views/shopfloor_menu.xml @@ -0,0 +1,29 @@ + + + + + shopfloor menu tree + + + + + + + + + + + shopfloor menu search + + + + + + + + + Menus + ir.actions.act_window + tree + + From 720e7de4bb605f22ceca4a6eae8a16f99df0a6f8 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 20 Jan 2020 17:20:56 +0100 Subject: [PATCH 005/710] 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 5ca90ecc73f..5b805f475d9 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 80936e68071260b0222afeee53f7a1f0217fc24d Mon Sep 17 00:00:00 2001 From: Benoit Date: Mon, 20 Jan 2020 17:23:12 +0100 Subject: [PATCH 006/710] 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 0650744f6bc..c312a8487c5 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 1a5fe43b700..94b9d46c509 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 00000000000..86d4e03e8fa --- /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 00000000000..18146f34619 --- /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 a5da888a87bac2bdc09daf03473009292438a09b Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:28:09 +0100 Subject: [PATCH 007/710] 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 2bf16a31f61..7215a23cb60 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 1aedf5f4a0a..212f672858a 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
@@ -31,6 +33,7 @@ shopfloor group search + shopfloor.group @@ -40,6 +43,7 @@ Groups + shopfloor.group ir.actions.act_window tree,form diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 9c50eafa7e7..461eda3a9fd 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -3,6 +3,7 @@ shopfloor menu tree + shopfloor.menu @@ -14,6 +15,7 @@ shopfloor menu search + shopfloor.menu @@ -23,6 +25,7 @@ Menus + shopfloor.menu ir.actions.act_window tree From fea964c1adb07b4caace10be8520deb55c936371 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:30:24 +0100 Subject: [PATCH 008/710] fixup! fixup! add some models --- shopfloor/views/shopfloor_group.xml | 6 +++--- shopfloor/views/shopfloor_menu.xml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/shopfloor/views/shopfloor_group.xml b/shopfloor/views/shopfloor_group.xml index 212f672858a..f705515e5c1 100644 --- a/shopfloor/views/shopfloor_group.xml +++ b/shopfloor/views/shopfloor_group.xml @@ -1,6 +1,6 @@ - + shopfloor group tree shopfloor.group @@ -11,7 +11,7 @@ - + shopfloor group form shopfloor.group @@ -31,7 +31,7 @@ - + shopfloor group search shopfloor.group diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 461eda3a9fd..b7648b2b120 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -1,7 +1,7 @@ - + shopfloor menu tree shopfloor.menu @@ -13,7 +13,7 @@ - + shopfloor menu search shopfloor.menu From 03679e0c1dbe244b8733b6d6c00a9ecb61e9b006 Mon Sep 17 00:00:00 2001 From: Benoit Date: Mon, 20 Jan 2020 17:32:59 +0100 Subject: [PATCH 009/710] fixup! typo --- shopfloor/services/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 86d4e03e8fa..5f9782abe91 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -1 +1 @@ -from . shopfloor_service +from . import shopfloor_service From c3a32abb3a56255d93f04d119f5a17bae29878b8 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:36:28 +0100 Subject: [PATCH 010/710] fixup! fixup! fixup! add some models --- shopfloor/views/menus.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml index c4a8cbb2f31..e79ab726b9f 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -1,6 +1,6 @@ -< - - - + + + + From 22ba1085974aaa5ee9ad1c00d8d70ac8665b374a Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:40:09 +0100 Subject: [PATCH 011/710] fixup! fixup! fixup! fixup! add some models --- shopfloor/views/menus.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml index e79ab726b9f..e29c43aa408 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -2,5 +2,5 @@ - + From 3e1bf45c040c7f6a37dd890eac7ad460ce9789b1 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 20 Jan 2020 17:42:23 +0100 Subject: [PATCH 012/710] fixup! fixup! fixup! fixup! fixup! add some models --- shopfloor/views/menus.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml index e29c43aa408..487df55ccf8 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -2,5 +2,5 @@ - + From 7de2e61987f3d1828af80ef71e74e29854719a5a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 09:27:41 +0100 Subject: [PATCH 013/710] Add dependency on auth_api_key Required to identify the access from the JS client. The module is stored in the repository: OCA/server-auth. --- shopfloor/__manifest__.py | 4 ++++ shopfloor/demo/auth_api_key_demo.xml | 9 +++++++++ shopfloor/readme/USAGE.rst | 7 ++----- 3 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 shopfloor/demo/auth_api_key_demo.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index a6b85e88b15..45b15a0aab5 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -15,6 +15,7 @@ "depends": [ "stock", "base_rest", + "auth_api_key", ], "data": [ "security/ir.model.access.csv", @@ -23,4 +24,7 @@ "views/shopfloor_menu.xml", "views/menus.xml", ], + "demo": [ + "demo/auth_api_key_demo.xml", + ] } diff --git a/shopfloor/demo/auth_api_key_demo.xml b/shopfloor/demo/auth_api_key_demo.xml new file mode 100644 index 00000000000..800aec5f0b8 --- /dev/null +++ b/shopfloor/demo/auth_api_key_demo.xml @@ -0,0 +1,9 @@ + + + + Demo + + 72B044F7AC780DAC + + + diff --git a/shopfloor/readme/USAGE.rst b/shopfloor/readme/USAGE.rst index 098af7f8dfd..a83522c0be6 100644 --- a/shopfloor/readme/USAGE.rst +++ b/shopfloor/readme/USAGE.rst @@ -1,5 +1,2 @@ -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 +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 From de1c936d2fa37c0c960ec260c5c0e3f00066a97f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 09:42:45 +0100 Subject: [PATCH 014/710] fixup! Add dependency on auth_api_key --- shopfloor/readme/USAGE.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shopfloor/readme/USAGE.rst b/shopfloor/readme/USAGE.rst index a83522c0be6..2d5767567e5 100644 --- a/shopfloor/readme/USAGE.rst +++ b/shopfloor/readme/USAGE.rst @@ -1,2 +1,6 @@ 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/shopfloor/get_pack" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" -d "{\"pack_name\":\"string\"}" From 88a4848b75ccf2704a3f1cc7319360e527312e8a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 09:42:35 +0100 Subject: [PATCH 015/710] fixup! add start controllers and services --- shopfloor/services/shopfloor_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/shopfloor_service.py b/shopfloor/services/shopfloor_service.py index 18146f34619..7db5a5c4b7c 100644 --- a/shopfloor/services/shopfloor_service.py +++ b/shopfloor/services/shopfloor_service.py @@ -14,7 +14,7 @@ def get_pack(self, pack_name): pack = self.env['stock.quant.package'].search([('name', '=', pack_name)]) return self._to_json(pack) - def _validator_search(self): + def _validator_get_pack(self): return {"pack_name": {"type": "string", "nullable": False, "required": True}} def _to_json(self, pack): From 3b8c3eb760a80beac7481e99c8069ca216c733f5 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Tue, 21 Jan 2020 11:05:24 +0100 Subject: [PATCH 016/710] [REF] rename shopfloor.group -> shopfloor.operation.group --- shopfloor/__manifest__.py | 2 +- shopfloor/models/__init__.py | 2 +- shopfloor/models/res_users.py | 6 ++--- shopfloor/models/shopfloor_menu.py | 4 ++-- ..._group.py => shopfloor_operation_group.py} | 6 ++--- shopfloor/security/ir.model.access.csv | 4 ++-- shopfloor/views/menus.xml | 2 +- shopfloor/views/res_users.xml | 2 +- shopfloor/views/shopfloor_menu.xml | 2 +- ...roup.xml => shopfloor_operation_group.xml} | 24 +++++++++---------- 10 files changed, 27 insertions(+), 27 deletions(-) rename shopfloor/models/{shopfloor_group.py => shopfloor_operation_group.py} (60%) rename shopfloor/views/{shopfloor_group.xml => shopfloor_operation_group.xml} (57%) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 45b15a0aab5..653d4f64911 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -20,7 +20,7 @@ "data": [ "security/ir.model.access.csv", "views/res_users.xml", - "views/shopfloor_group.xml", + "views/shopfloor_operation_group.xml", "views/shopfloor_menu.xml", "views/menus.xml", ], diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index d8addf9144d..6dca41f10ca 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -1,3 +1,3 @@ from . import shopfloor_menu -from . import shopfloor_group +from . import shopfloor_operation_group from . import res_users diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py index fe7db83dd45..5b6514def6d 100644 --- a/shopfloor/models/res_users.py +++ b/shopfloor/models/res_users.py @@ -4,9 +4,9 @@ class ResUsers(models.Model): _inherit = "res.users" - shopfloor_group_ids = fields.Many2many( - 'shopfloor.group', - string="Shopfloor groups" + shopfloor_operation_group_ids = fields.Many2many( + 'shopfloor.operation.group', + string="Shopfloor operation groups" ) shopfloor_current_process = fields.Char() shopfloor_last_call = fields.Char() diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 1e823b4e256..8e62c40d0ef 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -8,8 +8,8 @@ class ShopfloorMenu(models.Model): name = fields.Char(translate=True) sequence = fields.Integer() - group_ids = fields.Many2many( - 'shopfloor.group', + operation_group_ids = fields.Many2many( + 'shopfloor.operation.group', string="Groups", help="visible for these groups", ) diff --git a/shopfloor/models/shopfloor_group.py b/shopfloor/models/shopfloor_operation_group.py similarity index 60% rename from shopfloor/models/shopfloor_group.py rename to shopfloor/models/shopfloor_operation_group.py index 9e3ebb6b16b..1d78aeeb832 100644 --- a/shopfloor/models/shopfloor_group.py +++ b/shopfloor/models/shopfloor_operation_group.py @@ -1,9 +1,9 @@ from odoo import fields, models -class ShopfloorGroup(models.Model): - _name = "shopfloor.group" - _description = "Shopfloor group, governs which menu items are visible" +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") diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv index 5b805f475d9..6c0c0ddd14d 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,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 +"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 diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml index 487df55ccf8..3acc388d270 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -2,5 +2,5 @@ - + diff --git a/shopfloor/views/res_users.xml b/shopfloor/views/res_users.xml index 7215a23cb60..b1ce6c1e56d 100644 --- a/shopfloor/views/res_users.xml +++ b/shopfloor/views/res_users.xml @@ -10,7 +10,7 @@ - + diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index b7648b2b120..c9845601b00 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -8,7 +8,7 @@ - + diff --git a/shopfloor/views/shopfloor_group.xml b/shopfloor/views/shopfloor_operation_group.xml similarity index 57% rename from shopfloor/views/shopfloor_group.xml rename to shopfloor/views/shopfloor_operation_group.xml index f705515e5c1..28fdc3ecc36 100644 --- a/shopfloor/views/shopfloor_group.xml +++ b/shopfloor/views/shopfloor_operation_group.xml @@ -1,8 +1,8 @@ - - shopfloor group tree - shopfloor.group + + shopfloor operation group tree + shopfloor.operation.group @@ -11,9 +11,9 @@ - - shopfloor group form - shopfloor.group + + shopfloor operation group form + shopfloor.operation.group @@ -31,9 +31,9 @@ - - shopfloor group search - shopfloor.group + + shopfloor operation group search + shopfloor.operation.group @@ -41,9 +41,9 @@ - - Groups - shopfloor.group + + Operation Groups + shopfloor.operation.group ir.actions.act_window tree,form From 89bbf4c9eb1a556cdf1a8081ebc0216177fd1d2f Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Tue, 21 Jan 2020 11:22:55 +0100 Subject: [PATCH 017/710] [IMP] add shopfloor.process --- shopfloor/__manifest__.py | 2 ++ shopfloor/models/__init__.py | 2 ++ shopfloor/models/shopfloor_menu.py | 1 + shopfloor/models/shopfloor_process.py | 11 ++++++ shopfloor/models/stock_picking_type.py | 7 ++++ shopfloor/views/menus.xml | 1 + shopfloor/views/shopfloor_menu.xml | 1 + shopfloor/views/shopfloor_process.xml | 50 ++++++++++++++++++++++++++ shopfloor/views/stock_picking_type.xml | 13 +++++++ 9 files changed, 88 insertions(+) create mode 100644 shopfloor/models/shopfloor_process.py create mode 100644 shopfloor/models/stock_picking_type.py create mode 100644 shopfloor/views/shopfloor_process.xml create mode 100644 shopfloor/views/stock_picking_type.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 653d4f64911..7cbf3e87cd4 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -22,6 +22,8 @@ "views/res_users.xml", "views/shopfloor_operation_group.xml", "views/shopfloor_menu.xml", + "views/shopfloor_process.xml", + "views/stock_picking_type.xml", "views/menus.xml", ], "demo": [ diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index 6dca41f10ca..15fe538e931 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -1,3 +1,5 @@ from . import shopfloor_menu from . import shopfloor_operation_group +from . import shopfloor_process from . import res_users +from . import stock_picking_type diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 8e62c40d0ef..48f836bd371 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -13,3 +13,4 @@ class ShopfloorMenu(models.Model): string="Groups", help="visible for these groups", ) + process_id = fields.Many2one('shopfloor.process', name="Process") diff --git a/shopfloor/models/shopfloor_process.py b/shopfloor/models/shopfloor_process.py new file mode 100644 index 00000000000..accbc02155b --- /dev/null +++ b/shopfloor/models/shopfloor_process.py @@ -0,0 +1,11 @@ +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) + picking_type_ids = fields.One2many( + 'stock.picking.type', 'process_id', string="Operation types" + ) diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py new file mode 100644 index 00000000000..206b68c7fe4 --- /dev/null +++ b/shopfloor/models/stock_picking_type.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class StockPickingType(models.Model): + _inherit = 'stock.picking.type' + + process_id = fields.Many2one('shopfloor.process', string="Process") diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml index 3acc388d270..4fba7ea4281 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -3,4 +3,5 @@ + diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index c9845601b00..016b872f25a 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -9,6 +9,7 @@ + diff --git a/shopfloor/views/shopfloor_process.xml b/shopfloor/views/shopfloor_process.xml new file mode 100644 index 00000000000..67afa35bc8f --- /dev/null +++ b/shopfloor/views/shopfloor_process.xml @@ -0,0 +1,50 @@ + + + + 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/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml new file mode 100644 index 00000000000..1ccb8c591bf --- /dev/null +++ b/shopfloor/views/stock_picking_type.xml @@ -0,0 +1,13 @@ + + + + Operation Types + stock.picking.type + + + + + + + + From e65be9f7c5873fc817be36d9813df3c4b3b3d711 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 12:00:20 +0100 Subject: [PATCH 018/710] Add shopfloor device, with the associated service Adds a base component for rest to share things amongst service components. The service for packs still needs to be adapted. --- shopfloor/__manifest__.py | 2 +- shopfloor/models/__init__.py | 2 +- shopfloor/models/res_users.py | 13 ----- shopfloor/models/shopfloor_device.py | 25 +++++++++ shopfloor/security/ir.model.access.csv | 2 + shopfloor/services/__init__.py | 3 ++ shopfloor/services/device.py | 33 ++++++++++++ shopfloor/services/service.py | 54 +++++++++++++++++++ shopfloor/services/shopfloor_service.py | 4 +- shopfloor/views/menus.xml | 1 + shopfloor/views/res_users.xml | 25 --------- shopfloor/views/shopfloor_device_views.xml | 61 ++++++++++++++++++++++ 12 files changed, 183 insertions(+), 42 deletions(-) delete mode 100644 shopfloor/models/res_users.py create mode 100644 shopfloor/models/shopfloor_device.py create mode 100644 shopfloor/services/device.py create mode 100644 shopfloor/services/service.py delete mode 100644 shopfloor/views/res_users.xml create mode 100644 shopfloor/views/shopfloor_device_views.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 7cbf3e87cd4..7bae4764a91 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -19,11 +19,11 @@ ], "data": [ "security/ir.model.access.csv", - "views/res_users.xml", "views/shopfloor_operation_group.xml", "views/shopfloor_menu.xml", "views/shopfloor_process.xml", "views/stock_picking_type.xml", + "views/shopfloor_device_views.xml", "views/menus.xml", ], "demo": [ diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index 15fe538e931..7bfd287ebaf 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -1,5 +1,5 @@ from . import shopfloor_menu from . import shopfloor_operation_group from . import shopfloor_process -from . import res_users from . import stock_picking_type +from . import shopfloor_device diff --git a/shopfloor/models/res_users.py b/shopfloor/models/res_users.py deleted file mode 100644 index 5b6514def6d..00000000000 --- a/shopfloor/models/res_users.py +++ /dev/null @@ -1,13 +0,0 @@ -from odoo import fields, models - - -class ResUsers(models.Model): - _inherit = "res.users" - - shopfloor_operation_group_ids = fields.Many2many( - 'shopfloor.operation.group', - string="Shopfloor operation groups" - ) - shopfloor_current_process = fields.Char() - shopfloor_last_call = fields.Char() - shopfloor_picking_id = fields.Many2one('stock.picking') diff --git a/shopfloor/models/shopfloor_device.py b/shopfloor/models/shopfloor_device.py new file mode 100644 index 00000000000..986367f936c --- /dev/null +++ b/shopfloor/models/shopfloor_device.py @@ -0,0 +1,25 @@ +from odoo import fields, models + + +class ShopfloorDevice(models.Model): + _name = "shopfloor.device" + _description = "Shopfloor device settings" + + name = fields.Char(required=True) + warehouse_id = fields.Many2one( + "stock.warehouse", + required=True, + ) + shopfloor_operation_group_ids = fields.Many2many( + "shopfloor.operation.group", + string="Shopfloor Operation Groups" + ) + user_id = fields.Many2one( + "res.users", + help="Optional user using the device. The device will" + "use this configuration when the users logs in the client " + "application." + ) + shopfloor_current_process = fields.Char(readonly=True) + shopfloor_last_call = fields.Char(readonly=True) + shopfloor_picking_id = fields.Many2one('stock.picking', readonly=True) diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv index 6c0c0ddd14d..2726088b885 100644 --- a/shopfloor/security/ir.model.access.csv +++ b/shopfloor/security/ir.model.access.csv @@ -3,3 +3,5 @@ "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 diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 5f9782abe91..7369a5602f7 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -1 +1,4 @@ +from . import service +# TODO rename file shop_floor_service to pack from . import shopfloor_service +from . import device diff --git a/shopfloor/services/device.py b/shopfloor/services/device.py new file mode 100644 index 00000000000..74857cd9334 --- /dev/null +++ b/shopfloor/services/device.py @@ -0,0 +1,33 @@ +from odoo.addons.component.core import Component + + +class ShopfloorDevice(Component): + _inherit = "base.shopfloor.service" + _name = "shopfloor.device" + _usage = "device" + _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 _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, + "warehouse_id": record.warehouse_id.id, + "warehouse": record.warehouse_id.name, + } diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py new file mode 100644 index 00000000000..f52894f558a --- /dev/null +++ b/shopfloor/services/service.py @@ -0,0 +1,54 @@ +from odoo import _ +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" + _expose_model = None + + def _get(self, _id): + domain = expression.normalize_domain(self._get_base_search_domain()) + domain = expression.AND([domain, [("id", "=", _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) + ) + else: + return record + + def _get_base_search_domain(self): + return [] + + def _convert_one_record(record): + """To implement in service Components""" + return {} + + def _to_json(self, records): + res = [] + for record in records: + res.append(self._convert_one_record(record)) + return res + + 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 + defaults.append( + { + "name": "API-KEY", + "in": "header", + "description": "API key for Authorization", + "required": True, + "schema": {"type": "string"}, + "style": "simple", + "value": demo_api_key, + } + ) + return defaults diff --git a/shopfloor/services/shopfloor_service.py b/shopfloor/services/shopfloor_service.py index 7db5a5c4b7c..c3e68528b06 100644 --- a/shopfloor/services/shopfloor_service.py +++ b/shopfloor/services/shopfloor_service.py @@ -1,10 +1,10 @@ from odoo.addons.component.core import Component +# TODO move in a pack service class ShopfloorService(Component): - _inherit = "base.rest.service" + _inherit = "base.shopfloor.service" _name = "shopfloor.service" - _collection = "shopfloor.services" _usage = "shopfloor" def get_pack(self, pack_name): diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml index 4fba7ea4281..f6bc4f105da 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -4,4 +4,5 @@ +
diff --git a/shopfloor/views/res_users.xml b/shopfloor/views/res_users.xml deleted file mode 100644 index b1ce6c1e56d..00000000000 --- 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 00000000000..2f92f309280 --- /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 4d4efc4016844c7999781003504bf571cd1f0a85 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 13:55:19 +0100 Subject: [PATCH 019/710] 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 2726088b885..6db6c867996 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 4d039c9e32476258d79f526cee4e03366a915d10 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 14:09:58 +0100 Subject: [PATCH 020/710] 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 7bfd287ebaf..68782904869 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 00000000000..e86bb27d2cc --- /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 986367f936c..f3f8ffc56b3 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 74857cd9334..5edd4a565b8 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 2f92f309280..d1805604c79 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 79545015fe3d4cd1813c2567ae571c4a552f7273 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 14:36:06 +0100 Subject: [PATCH 021/710] 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 5edd4a565b8..f92d1a18d11 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 be4837eb2a75a6ed383e1048b374fe91316b8530 Mon Sep 17 00:00:00 2001 From: Benoit Date: Tue, 21 Jan 2020 15:14:56 +0100 Subject: [PATCH 022/710] 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 c3e68528b06..3e14289e332 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 00000000000..3a80e1ce963 --- /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 00000000000..f85f3c3f947 --- /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 00000000000..45b87743d67 --- /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 6cc33d5706970ecae51cc6030ef911e48e416288 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 15:16:51 +0100 Subject: [PATCH 023/710] 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 7bae4764a91..5110d5a2eb4 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 00000000000..bd9ed3f2507 --- /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 00000000000..4f8104efb52 --- /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 f3f8ffc56b3..bd32e5c2e19 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 0ba80b4a2e3511271db771ddf450f78efed72849 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 15:36:12 +0100 Subject: [PATCH 024/710] 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 5110d5a2eb4..f94a70f42e9 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 00000000000..7d643a0e1f9 --- /dev/null +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -0,0 +1,9 @@ + + + + Put-Away Reach Truck + 10 + + + + diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml new file mode 100644 index 00000000000..30964d7b23c --- /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 bd32e5c2e19..ab47e8ea2f8 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 7369a5602f7..60ec641820f 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 00000000000..c61f5c66b63 --- /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 44c2dce9375893d2ee58f6df0fbfcadcd4750565 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 15:43:13 +0100 Subject: [PATCH 025/710] 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 ab47e8ea2f8..f84c78322e3 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 f92d1a18d11..c53060838e9 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 d1805604c79..1f6dc4cc251 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 6e31a6956d0ed361d683a949897347e8c007e62d Mon Sep 17 00:00:00 2001 From: Benoit Date: Tue, 21 Jan 2020 16:03:21 +0100 Subject: [PATCH 026/710] [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 c53060838e9..8e6ede204a8 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 018114b80c0ea5a1afcf95762a92664253da33bf Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 16:16:30 +0100 Subject: [PATCH 027/710] 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 c61f5c66b63..c4270d1b9a5 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 61cc61f3070ae9a7d24972769f088704607b066b Mon Sep 17 00:00:00 2001 From: Benoit Date: Tue, 21 Jan 2020 16:24:28 +0100 Subject: [PATCH 028/710] 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 94b9d46c509..4a95642ab99 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 f52894f558a..7d6cdb05dde 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 8e70fd6fce51554da24e0b5fb166ae9074070f24 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 16:46:15 +0100 Subject: [PATCH 029/710] 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 60ec641820f..1fa6f94a433 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 00000000000..e0dcd1980e3 --- /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 3e14289e332..00000000000 --- 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 0e78f769309a5768dab4d47ba11e6a4c0fdfb0b8 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 16:48:38 +0100 Subject: [PATCH 030/710] 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 8e6ede204a8..b2609f4245f 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 c4270d1b9a5..3fdf5182a09 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 e0dcd1980e3..bac63d79bbf 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 7743038597c3728211beb98385b9a5afc46dc9af Mon Sep 17 00:00:00 2001 From: Benoit Date: Tue, 21 Jan 2020 16:56:31 +0100 Subject: [PATCH 031/710] 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 4a95642ab99..5909552712b 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 a1357f7e74b0695657b8dad12d646793b552796a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 17:14:42 +0100 Subject: [PATCH 032/710] 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 5909552712b..4df8196e6ea 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 bac63d79bbf..bf098599a30 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 7d6cdb05dde..d19ba3db273 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 45b87743d67..53c325cb0c4 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 77ab64d4d52967c0627b86b009c9081aa1d0a977 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 21 Jan 2020 17:20:33 +0100 Subject: [PATCH 033/710] 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 f94a70f42e9..b4043333aa6 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 4df8196e6ea..34be9aae064 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 e86bb27d2cc..6838b582ee9 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 f84c78322e3..25acbe2dc6c 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 48f836bd371..f95d37c382a 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 1d78aeeb832..0dc47fe272a 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 accbc02155b..b1daa88f4df 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 206b68c7fe4..808bf505526 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 f85f3c3f947..5ae4ec521d0 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 4b6305670e9daacdeff49ac3377f948a3c5b2b32 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 12:54:45 +0100 Subject: [PATCH 034/710] 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 b2609f4245f..f3605592bc5 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 3fdf5182a09..8f8b8631402 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 b526a70df4c991e1365dcb6410c01e25d03a6969 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 12:55:13 +0100 Subject: [PATCH 035/710] 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 1fa6f94a433..7a379146daa 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 00000000000..2ed25b05a1b --- /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 9dda8eef9bd84b3aca761e6a386e2fbc0b5c09f2 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 13:20:09 +0100 Subject: [PATCH 036/710] 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 b4043333aa6..bfd999f5f00 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 bd9ed3f2507..918ad669e2b 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 68782904869..1aa3c9fbc8a 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 6838b582ee9..8c136f725e7 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 25acbe2dc6c..f18d813b31b 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 6db6c867996..a616c579eec 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 7a379146daa..f72a9992432 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 2ed25b05a1b..8f65a1febb4 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 f3605592bc5..001a31a52e8 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 f6bc4f105da..71f19f9f78f 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 1f6dc4cc251..26720fae2bb 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 a833c1efebfcde8f57e51386a59f9f0a1b0a3df9 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 13:41:36 +0100 Subject: [PATCH 037/710] 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 5ae4ec521d0..1f14f5ac93e 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 c132463aa74bc0e17758586cef838666b2a72bf0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 15:48:07 +0100 Subject: [PATCH 038/710] 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 8f65a1febb4..17aac7be05c 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 8f8b8631402..e9d7fbfc50d 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 bf098599a30..32f7a1b20fa 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 001a31a52e8..92f9d3eac32 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 d19ba3db273..d48cfe3f781 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 4f74e836b129bfa06fcef654303f06306cc3b695 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 16:50:27 +0100 Subject: [PATCH 039/710] 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 7d643a0e1f9..6d0d511f791 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -6,4 +6,10 @@ + + Single Pallet Transfer + 20 + + +
diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml index 30964d7b23c..3f869cb4579 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 918ad669e2b..5ef9df4179d 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 f95d37c382a..0f0a56e2bbd 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 b1daa88f4df..db4759c2d01 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 f72a9992432..bb82a1df2e5 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 e9d7fbfc50d..220003a5599 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 00000000000..f46024bd0dc --- /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 00000000000..a22659a1cb7 --- /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 67afa35bc8f..db58ed0b343 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 e9bce40cc50f249fe84b14ccb1322979151a99b6 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 22 Jan 2020 17:27:15 +0100 Subject: [PATCH 040/710] 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 34be9aae064..297b0e40332 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 d48cfe3f781..2363b60af48 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 0cb3310cf3ac15c23ec5137a2783c62f1636eb0f Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 10:04:30 +0100 Subject: [PATCH 041/710] 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 32f7a1b20fa..9cca11de74e 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 53c325cb0c4..5b09c332413 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 8f5be6e739af1b6743da7fcce7a7c94b863714d5 Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 10:13:44 +0100 Subject: [PATCH 042/710] 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 9cca11de74e..46b70147646 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 f46024bd0dc..3c68068915c 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 6f2990a8770f1ca5ded8700bbbcb4fa7b099b12e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 11:41:42 +0100 Subject: [PATCH 043/710] 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 17aac7be05c..2a3f5ea3557 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 220003a5599..374772d9c28 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 46b70147646..2f8a591621a 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 92f9d3eac32..2ccf78be0fe 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 2363b60af48..91620efc21b 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 3c68068915c..a5a982c370d 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 f2694ffa2381a3aaae0246d0f2b8f13081885aa8 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 12:01:51 +0100 Subject: [PATCH 044/710] 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 2a3f5ea3557..a7898691201 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 374772d9c28..33ea065b67d 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 2f8a591621a..e13ea02c5ef 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 2ccf78be0fe..86e2ed1175a 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 91620efc21b..d96822cb43b 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 9f076ef7efe31f40122d5d4608332e9187c37a3e Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 11:53:04 +0100 Subject: [PATCH 045/710] 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 a5a982c370d..9192526ebc6 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 3a80e1ce963..188a3788ab0 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 5b09c332413..82a8e6c6c29 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 edc4aed09a4c797db77dd70f7cec05111bbea79f Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 12:20:53 +0100 Subject: [PATCH 046/710] 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 9192526ebc6..18de0687df6 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 00892e8838decf4898bd9f289acd748094ebcce1 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 13:30:37 +0100 Subject: [PATCH 047/710] 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 18de0687df6..80d20d5a3dd 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 711c6e125a3667070dd3da2cffd7febcab357f0e Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 14:09:48 +0100 Subject: [PATCH 048/710] 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 80d20d5a3dd..52f3994b3ec 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 82a8e6c6c29..7d2057b370a 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 0423260d70a29251bf2c0484f83b4ea138292110 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 14:39:48 +0100 Subject: [PATCH 049/710] 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 c312a8487c5..6a34e5681f2 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 00000000000..b9b5c7e5511 --- /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 00000000000..e7d5bbda5b9 --- /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 00000000000..dd48a12da67 --- /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 d96822cb43b..f9bfd90518c 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 52f3994b3ec..cbf6154eb33 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 a22659a1cb7..e29fdbf56cc 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 9a548a844c5b8a893753edfc2756d95f7ce498a5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 15:11:17 +0100 Subject: [PATCH 050/710] 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 bb82a1df2e5..5eb564a6a69 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 00000000000..ed1bbf4880e --- /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 33ea065b67d..96847ce7c43 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 86e2ed1175a..9f59ef0706f 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 8a9c82fa51a7427aaf4ea8329e89bf8af9607501 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 15:14:20 +0100 Subject: [PATCH 051/710] 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 cbf6154eb33..b22e7797486 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 eaccd114c9017bb2c77fd3dee437c3194246563c Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 15:19:35 +0100 Subject: [PATCH 052/710] 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 f9bfd90518c..7b47800d1b3 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 b22e7797486..38dbea6ce09 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 7d2057b370a..7e72d52939d 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 e1cce5e79a407f5e733ff4b45667ed7c65c17dbf Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 16:28:24 +0100 Subject: [PATCH 053/710] 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 b9b5c7e5511..af4a9151a56 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 e7d5bbda5b9..e026e1c1e14 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 00000000000..bbfe013aca8 --- /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 dd48a12da67..00000000000 --- 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 7b47800d1b3..0b85c499584 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 38dbea6ce09..f0c28df1b83 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 e29fdbf56cc..204e9656ecb 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 2fb5822d9b1a0e8756a8d12424d3dbc6a5b007a1 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jan 2020 16:53:06 +0100 Subject: [PATCH 054/710] 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 204e9656ecb..6aee17f2a43 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 99ec4429df1f29813c3d6a39569c1ec57cd51e5d Mon Sep 17 00:00:00 2001 From: Benoit Date: Thu, 23 Jan 2020 18:07:24 +0100 Subject: [PATCH 055/710] 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 f0c28df1b83..8647f9089f7 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 6aee17f2a43..dbf0784e5ee 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 7e72d52939d..5347ba99c2e 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 60fd7a3b99e6a7db389c6c5638cc633ceac7982d Mon Sep 17 00:00:00 2001 From: Benoit Date: Fri, 24 Jan 2020 09:25:43 +0100 Subject: [PATCH 056/710] 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 8647f9089f7..a4c5851cd01 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 dbf0784e5ee..26abb6e18dc 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 ead369d2d5f3ce61c8daa8ee476f9507401f0a62 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 24 Jan 2020 12:02:30 +0100 Subject: [PATCH 057/710] 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 bbfe013aca8..8caef8bfc11 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 a4c5851cd01..15483cc1522 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 5347ba99c2e..8bb1181ca72 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 bad1887b8bc8396fdd35cda2244ef6b366ce2351 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 24 Jan 2020 15:00:29 +0100 Subject: [PATCH 058/710] 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 bfd999f5f00..69f064b2c23 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 8caef8bfc11..1404f10d497 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 00000000000..c6bb3bae228 --- /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 15483cc1522..8f4fc93a23e 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 26abb6e18dc..2aea9d7ae7f 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 8bb1181ca72..8986341e592 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 7f99b5a83387070cd28411b11c7d4bc38ec4cd41 Mon Sep 17 00:00:00 2001 From: Benoit Date: Fri, 24 Jan 2020 15:44:16 +0100 Subject: [PATCH 059/710] 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 8f4fc93a23e..6e287dc986f 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 3e06efe11185411d63df22384cf709e87f56d938 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 31 Jan 2020 14:25:57 +0100 Subject: [PATCH 060/710] 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 6e287dc986f..ef890af2ac2 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 13861f4dfcee2880c0403543dc339d8eb51c1453 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 31 Jan 2020 15:12:19 +0100 Subject: [PATCH 061/710] 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 0b85c499584..bfed200be30 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 ef890af2ac2..22ab2976c2c 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 8986341e592..4ef3078e222 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 693cc5f4a39c1951b1711d8c99b476a48a73923a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 4 Feb 2020 16:54:48 +0100 Subject: [PATCH 062/710] 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 | 21 +- shopfloor/tests/test_single_pack_putaway.py | 308 +++++++++++++++++++- 4 files changed, 318 insertions(+), 14 deletions(-) diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py index e026e1c1e14..76eac66eba3 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 1404f10d497..19c5f25ccb1 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 22ab2976c2c..fe0c27f1392 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -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) @@ -203,6 +204,16 @@ def _response_for_move_canceled(self): }, ) + 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."), + }, + ) + def _response_for_forbidden_location(self): return self._response( state="scan_location", @@ -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,6 +277,10 @@ 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", message={ @@ -271,8 +289,7 @@ def cancel(self, package_level_id): "body": _("This operation does not exist anymore."), }, ) - # TODO cancel() does not exist - package.move_ids[0].cancel() + package.move_line_ids.move_id._action_cancel() return self._response( state="scan_pack", message={ diff --git a/shopfloor/tests/test_single_pack_putaway.py b/shopfloor/tests/test_single_pack_putaway.py index 4ef3078e222..062da81f3ee 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,7 +123,7 @@ def test_validate(self): "validate", params={ "package_level_id": package_level.id, - "location_barcode": self.shelf2.barcode, + "location_barcode": self.shelf1.barcode, }, ) self.assertDictEqual( @@ -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 c1342b8f13f2cbe160ff2cccb64821d7382953a7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 09:47:28 +0100 Subject: [PATCH 063/710] 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 | 17 +++++++++++------ shopfloor/services/single_pack_transfer.py | 12 +++++++++--- 6 files changed, 44 insertions(+), 22 deletions(-) create mode 100644 shopfloor/actions/search.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index af4a9151a56..0a39e1f9af1 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 19c5f25ccb1..74af3071db5 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 00000000000..706b6c057ee --- /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 e13ea02c5ef..06117ef4833 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 fe0c27f1392..0f116738416 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="scan_pack", @@ -131,14 +133,15 @@ def scan_pack(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)] ) @@ -160,6 +163,8 @@ def scan_pack(self, barcode): 0 ].product_id # FIXME we consider only one product per pack 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 +252,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 +262,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 2aea9d7ae7f..f3fc1df67d3 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 e720c4400535e17bd30f944d3e27203f25d49c7e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 11:40:23 +0100 Subject: [PATCH 064/710] 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 | 26 ++-- shopfloor/services/single_pack_transfer.py | 106 ++++----------- shopfloor/tests/test_single_pack_putaway.py | 12 +- 5 files changed, 191 insertions(+), 96 deletions(-) create mode 100644 shopfloor/actions/message.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index 0a39e1f9af1..a6719c5d2f9 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 00000000000..3d19bcf64b4 --- /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 0f116738416..46fa743136b 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -12,8 +12,6 @@ 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="scan_pack", @@ -35,6 +33,7 @@ def _response_for_several_picking_types(self): ) def _response_for_package_not_found(self, barcode): + message = self.actions_for("message") return self._response( state="scan_pack", message={ @@ -45,6 +44,7 @@ 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="scan_pack", message={ @@ -199,7 +199,8 @@ def _response_for_package_level_not_found(self): }, ) - def _response_for_move_canceled(self): + def _response_for_move_canceled_elsewhere(self): + message = self.actions_for("message") return self._response( state="scan_pack", message={ @@ -211,12 +212,7 @@ def _response_for_move_canceled(self): 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."), - }, + state="scan_location", message=message.no_location_found() ) def _response_for_forbidden_location(self): @@ -260,7 +256,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: @@ -279,6 +275,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(): diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index f3fc1df67d3..1e268273246 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 062da81f3ee..ffde6f82cef 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 922229dd217a28fba1f67a61d259bacfe26281ff Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 12:04:14 +0100 Subject: [PATCH 065/710] Use _prepare methods in putaway --- shopfloor/services/single_pack_putaway.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 46fa743136b..0036aa49069 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -164,8 +164,7 @@ def scan_pack(self, barcode): ].product_id # FIXME we consider only one product per pack 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, @@ -175,13 +174,13 @@ def scan_pack(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, } ) From e4182a66a490770ffb5a4a47a7ad6bf2c4612064 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 15:14:40 +0100 Subject: [PATCH 066/710] Add test utilities to check webservice responses --- shopfloor/tests/common.py | 77 +++++++++++++++ shopfloor/tests/test_single_pack_putaway.py | 104 +++++++++----------- 2 files changed, 124 insertions(+), 57 deletions(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 1f14f5ac93e..372fbd187c0 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 ffde6f82cef..98d541ee6f1 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -126,7 +126,8 @@ def test_validate(self): "location_barcode": self.shelf1.barcode, }, ) - self.assertDictEqual( + + self.assert_response( response, { "message": { @@ -158,15 +159,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 +192,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 +229,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 +269,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 +312,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 +352,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 +370,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 8ff34a7568a6bc3b1413df00a5a6a6b0b9dd9199 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 17:18:33 +0100 Subject: [PATCH 067/710] 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 | 47 +++- shopfloor/tests/test_single_pack_putaway.py | 234 ++++++++++++++++++++ 4 files changed, 284 insertions(+), 17 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 3d19bcf64b4..d008b58d8d1 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 74af3071db5..4997e402eff 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 0036aa49069..51a3a158fd9 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): return self._response( state="scan_pack", @@ -142,19 +144,35 @@ def scan_pack(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_scan_pack(existing_operations) elif existing_operations: @@ -265,9 +283,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() @@ -318,6 +342,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 98d541ee6f1..371192a6a85 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 @@ -86,7 +87,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: @@ -240,6 +381,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 c03ac7ecf7d12c56ab4b362add8938a4141a8c71 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 17:20:21 +0100 Subject: [PATCH 068/710] 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 371192a6a85..15e2cf1eeef 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 @@ -123,8 +123,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( @@ -135,7 +135,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, ), }, @@ -153,7 +153,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) @@ -161,7 +161,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() @@ -193,7 +193,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() @@ -231,13 +231,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 0f8099a9da0f6ac97cecf52fed0c634499176aba Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 6 Feb 2020 17:23:20 +0100 Subject: [PATCH 069/710] 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/tests/common.py | 1 - shopfloor/tests/test_single_pack_putaway.py | 28 ++------------- 3 files changed, 7 insertions(+), 61 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index d008b58d8d1..3d1a528dfb3 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/tests/common.py b/shopfloor/tests/common.py index 372fbd187c0..682656bab27 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 15e2cf1eeef..aa28fac0efd 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -104,7 +104,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", }, @@ -131,7 +130,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" % ( @@ -173,7 +171,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,), @@ -205,7 +202,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?", @@ -305,7 +301,6 @@ def test_validate_not_found(self): response, state="start", message={ - "title": self.ANY, "message_type": "error", "message": "This operation does not exist anymore.", }, @@ -338,7 +333,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.", }, @@ -374,11 +368,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): @@ -413,11 +403,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): @@ -459,7 +445,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.", }, @@ -507,7 +492,6 @@ def test_cancel(self): response, state="start", message={ - "title": self.ANY, "message_type": "info", "message": "Canceled, you can scan a new pack.", }, @@ -550,7 +534,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.", }, @@ -589,11 +572,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): @@ -608,7 +587,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 5fe3bfb7fa93aabb9c337d9fed593c82dab2dfca Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 7 Feb 2020 08:29:10 +0100 Subject: [PATCH 070/710] 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 4997e402eff..a53998e7480 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 1aa3c9fbc8a..d0299a28b9c 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 00000000000..6ff8d3a4e8c --- /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 51a3a158fd9..af075c57949 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -142,15 +142,7 @@ def scan_pack(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( @@ -285,12 +277,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 06d909bf99f5ea407000c9c77493f212be3ae9d8 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 7 Feb 2020 14:40:00 +0100 Subject: [PATCH 071/710] 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 | 3 + 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, 793 insertions(+), 59 deletions(-) create mode 100644 shopfloor/tests/test_single_pack_transfer.py diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 3d1a528dfb3..98557b39cdc 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 a53998e7480..7de53656cd4 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 c6bb3bae228..6e473b5de62 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 af075c57949..d14d80d291d 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -325,6 +325,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 1e268273246..5c242a075bd 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 682656bab27..8b58279f0df 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 aa28fac0efd..e4f026ab79b 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, @@ -177,8 +170,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: @@ -188,7 +183,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 @@ -215,7 +210,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 00000000000..df30c618451 --- /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 d30a0c0bbec3754c0c962357e5bc4cfa51941123 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 7 Feb 2020 15:11:28 +0100 Subject: [PATCH 072/710] 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 69f064b2c23..a532100ae36 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 6e473b5de62..a6a96d9d69a 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 5c242a075bd..1d44a60d174 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 df30c618451..dff4999c3d3 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 600955ec143346c7f0b45efea5283c6f8d738151 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 10 Feb 2020 13:34:40 +0100 Subject: [PATCH 073/710] 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 db4759c2d01..751eecafe74 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 bfed200be30..3063cd720b1 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 @@ -100,6 +100,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( [ { @@ -118,7 +128,7 @@ def _get_openapi_default_parameters(self): "required": True, "schema": {"type": "integer"}, "style": "simple", - "value": "1", + "value": menu.id, }, { "name": "SERVICE_CTX_PROFILE_ID", @@ -127,7 +137,7 @@ def _get_openapi_default_parameters(self): "required": True, "schema": {"type": "integer"}, "style": "simple", - "value": "1", + "value": profile.id, }, ] ) From f81bc9b94eeff15c06ba4a2e61ea4c11032e0181 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 11 Feb 2020 08:07:40 +0100 Subject: [PATCH 074/710] 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 d14d80d291d..d87242e7a67 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -142,7 +142,10 @@ def scan_pack(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 c81735f19d2335695875eafc86ef19c839196094 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 11 Feb 2020 08:09:47 +0100 Subject: [PATCH 075/710] 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 96847ce7c43..77670ff2e71 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 52b42648743655724537fea3001456dbd5d2f2fc Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 11 Feb 2020 11:48:28 +0100 Subject: [PATCH 076/710] 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 297b0e40332..e2a7dd79b25 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 fbf5c78f1a3122577575ebb8779a6de1c4507aae Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 11 Feb 2020 15:52:15 +0100 Subject: [PATCH 077/710] 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 6d0d511f791..f19f603dfbf 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -12,4 +12,10 @@ + + Cluster Picking + 30 + + + diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml index 3f869cb4579..f2aab9b2122 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 a6a96d9d69a..80792ca4792 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 751eecafe74..24890d6a590 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 5eb564a6a69..73b5657920b 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 00000000000..8493e6299db --- /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 188a3788ab0..b9fe5004f94 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 00000000000..dd53151e594 --- /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 f9e1163f08d42c50352c7d04359b25aaa88d9012 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 11 Feb 2020 16:18:32 +0100 Subject: [PATCH 078/710] 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 77670ff2e71..e62fa87ae2e 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 abcf9abc2381676341b51793129eac6d46ac649e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 12 Feb 2020 12:29:36 +0100 Subject: [PATCH 079/710] 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 a532100ae36..d2dbcaa11f2 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 e2a7dd79b25..87ce6ef967e 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 73b5657920b..f3b34ed4336 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 00000000000..df580ba85fc --- /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 b9fe5004f94..b9d6b8a20e0 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 8b58279f0df..52b4d05aa56 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 00000000000..62e4f70a38b --- /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 f3407c4dde6ddd1b91b70ecf134c11963b588091 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 13 Feb 2020 11:42:18 +0100 Subject: [PATCH 080/710] 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 | 14 +++ shopfloor/services/single_pack_putaway.py | 28 +++-- shopfloor/services/single_pack_transfer.py | 118 ++++++++++++--------- shopfloor/services/validator.py | 45 ++++++++ 11 files changed, 312 insertions(+), 124 deletions(-) create mode 100644 shopfloor/services/validator.py diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index f3b34ed4336..7a137fed0db 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 ed1bbf4880e..aa70424129f 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 a7898691201..7bd237f4bd2 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 e62fa87ae2e..fd2a015b869 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 06117ef4833..bf4d3ea2f39 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 df580ba85fc..a4d2cfe2d84 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 9f59ef0706f..395971a73ae 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 3063cd720b1..804f383a413 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 diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index d87242e7a67..45aa43a372e 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -323,22 +323,38 @@ def cancel(self, package_level_id): }, ) - 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_scan_pack(self): diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 1d44a60d174..832321b17c5 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 00000000000..0ecee638b7c --- /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 b8e3ee9c2ef47a5a5abc3910d94a973f477104b4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 13 Feb 2020 11:48:05 +0100 Subject: [PATCH 081/710] 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 8493e6299db..42211235fa1 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 dd53151e594..6157f8e552c 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 a405857f8cc810ac6fc3d320b35115e0a92b936f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 13 Feb 2020 13:34:07 +0100 Subject: [PATCH 082/710] 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 | 6 +-- shopfloor/services/single_pack_transfer.py | 40 +++++++++++--------- shopfloor/services/validator.py | 2 +- shopfloor/tests/common.py | 8 ++-- shopfloor/tests/test_single_pack_putaway.py | 26 ++++++------- shopfloor/tests/test_single_pack_transfer.py | 38 +++++++++---------- 8 files changed, 68 insertions(+), 63 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 42211235fa1..56aa0242066 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 804f383a413..ab94e700306 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 45aa43a372e..3c3387d4dd0 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -224,7 +224,7 @@ def _response_for_move_canceled_elsewhere(self): def _response_for_location_not_found(self): 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): @@ -290,12 +290,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 832321b17c5..65e465decd5 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 0ecee638b7c..48d8d44e96c 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 52b4d05aa56..45bd0c4cf37 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 e4f026ab79b..454d4cf67e3 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -95,7 +95,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", @@ -121,7 +121,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" @@ -162,7 +162,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" @@ -195,7 +195,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." @@ -294,7 +294,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.", @@ -326,7 +326,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.", @@ -362,7 +362,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"}, ) @@ -397,7 +397,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?"}, ) @@ -438,7 +438,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.", @@ -485,7 +485,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.", @@ -527,7 +527,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.", @@ -566,7 +566,7 @@ def test_cancel_already_done(self): self.assert_response( response, - state="start", + next_state="start", message={"message_type": "info", "message": "Operation already processed."}, ) @@ -580,7 +580,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 dff4999c3d3..fd02731953f 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 d6a170a2429cf60a2432c53ad7c92340e4e69254 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 13 Feb 2020 16:20:38 +0100 Subject: [PATCH 083/710] 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 | 88 ++++--- 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, 321 insertions(+), 280 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 56aa0242066..db625c25cc2 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 ab94e700306..f6067d7a455 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 _response_schema(self, data_schema=None): diff --git a/shopfloor/services/single_pack_putaway.py b/shopfloor/services/single_pack_putaway.py index 3c3387d4dd0..00521368e98 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -224,7 +224,9 @@ def _response_for_move_canceled_elsewhere(self): def _response_for_location_not_found(self): 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): @@ -266,15 +268,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: @@ -354,44 +357,61 @@ 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 _validator_scan_pack(self): return {"barcode": {"type": "string", "nullable": False, "required": True}} def _validator_return_scan_pack(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 65e465decd5..4cb233bc69f 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 48d8d44e96c..67b2b16b385 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 45bd0c4cf37..2c7aed84faf 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 6157f8e552c..7fd1d38430d 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 62e4f70a38b..374261de592 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 454d4cf67e3..d6c107471b4 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 @@ -193,6 +198,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", @@ -331,6 +337,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): @@ -364,6 +371,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): @@ -520,6 +528,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 fd02731953f..acbd4aef889 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 b4081ab9b7e74855ab53731375a9c5a25262ef53 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 14 Feb 2020 15:49:56 +0100 Subject: [PATCH 084/710] 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 d0299a28b9c..b39ee610aa1 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 00000000000..4101de4da59 --- /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 db625c25cc2..9165382afbc 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 f6067d7a455..57eb38f816e 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 81021dc9d2179ca261426b48d4e0b88b6525add0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 08:42:57 +0100 Subject: [PATCH 085/710] 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 fd2a015b869..140c3bfde39 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 a4d2cfe2d84..3646ce850cd 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 395971a73ae..94ee11d5ed6 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 93a0a103470c53b49fb59f2e7d841284a1de1ac7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 09:46:21 +0100 Subject: [PATCH 086/710] 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 9165382afbc..d6bb3915e97 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 3646ce850cd..e6d22c82c75 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 2c7aed84faf..386d0c4956e 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 7fd1d38430d..f022cf7faf3 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 8526635d8c1b7b695e751b7624b8285e808662ec Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 11:55:05 +0100 Subject: [PATCH 087/710] 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 d6bb3915e97..4854799dce3 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 e6d22c82c75..90716d83c07 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 f022cf7faf3..64e4c26417a 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 61c1b66688e5e73044ace6bc825ac1471e5bd6d6 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 12:18:02 +0100 Subject: [PATCH 088/710] 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 4854799dce3..7b6b5e84bf2 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 64e4c26417a..0be61f2fcdc 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 ac60b98d639edeb975cde1a2aef5f38b9f8c4414 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 15:46:50 +0100 Subject: [PATCH 089/710] 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 7b6b5e84bf2..7ec38bcfef9 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 087292d100a91d9ac76409c8c93f431b352d80b4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 15:50:23 +0100 Subject: [PATCH 090/710] 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 aa70424129f..80bfbfd4449 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 fb534a833c4cabf4aa1f7b4a5bbf6f90bd6847bf Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Feb 2020 16:12:45 +0100 Subject: [PATCH 091/710] 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 d2dbcaa11f2..b48d72b8198 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 f2aab9b2122..660079c145b 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 80792ca4792..653cac77592 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 24890d6a590..bff78f93e3b 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 808bf505526..80374d89b9b 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 a616c579eec..41e5dd07c56 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 90716d83c07..83cb0b2d718 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 57eb38f816e..503ee3bc204 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 00521368e98..c694aadc0af 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -130,7 +130,7 @@ def _response_for_scan_pack_success(self, move, pack): def scan_pack(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 4cb233bc69f..550ee27ec8b 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 0be61f2fcdc..7b12480303d 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 374261de592..c11bee3a060 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 d6c107471b4..bd759fbbbcd 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -132,7 +132,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, ), }, ) @@ -226,7 +226,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 acbd4aef889..7679bef1b7a 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 db58ed0b343..25821c60fc4 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 1ccb8c591bf..c5ba2996ffa 100644 --- a/shopfloor/views/stock_picking_type.xml +++ b/shopfloor/views/stock_picking_type.xml @@ -6,7 +6,7 @@ - + From 8ada3529d80c1b84abe4e6f9aea30a2cf57ae5ff Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 18 Feb 2020 14:45:41 +0100 Subject: [PATCH 092/710] 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 80bfbfd4449..997c33eda4c 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 fcbc0454e46bb4f6790b8639b79e0ad6f0ce287b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 18 Feb 2020 15:20:34 +0100 Subject: [PATCH 093/710] 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 386d0c4956e..bae79cc4ced 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 becfc4ed01b1bf5c26493340f48e4ccd1a0bbb84 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 18 Feb 2020 15:28:14 +0100 Subject: [PATCH 094/710] 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 b9d6b8a20e0..91bcbf4ea59 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 00000000000..e431ba588b8 --- /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 00000000000..98952c5c2e2 --- /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 00000000000..7a2fc1f4e09 --- /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 a4349d491a852ba77dd8b4bd9eb372450180b525 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 20 Feb 2020 10:14:08 +0100 Subject: [PATCH 095/710] 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 7a137fed0db..6a8054d8e04 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 00000000000..6e2c95d8b4e --- /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 91bcbf4ea59..e4331331dd9 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 00000000000..7cd5133cdd9 --- /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 2692d50c81aa7d5fe59c0ceee48997c251d6609a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 20 Feb 2020 11:36:23 +0100 Subject: [PATCH 096/710] 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 7ec38bcfef9..9de530828cb 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 83cb0b2d718..2838373dc37 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 7b12480303d..e32c957a932 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 269497874a014c673b585a5e8d36274ec45223f7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 20 Feb 2020 14:47:03 +0100 Subject: [PATCH 097/710] 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 98557b39cdc..0cc3d56ee20 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 9de530828cb..65d517f8968 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 503ee3bc204..b21559b81ad 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 bae79cc4ced..06f866967e5 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 e32c957a932..d485ce770e1 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 c11bee3a060..d053eefee4b 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 79fe5a88f534efc41670b8b05f8d30fd617e9f5b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 20 Feb 2020 15:13:29 +0100 Subject: [PATCH 098/710] 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 06f866967e5..99bea23eb88 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 d485ce770e1..061f316edc2 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 d053eefee4b..b40aa8726d3 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 2d194f7e3d10b0bc895bee778bf0c1eeae8d146c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 21 Feb 2020 11:43:12 +0100 Subject: [PATCH 099/710] 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 0cc3d56ee20..448e54c3155 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 65d517f8968..835cbac492b 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 99bea23eb88..8f4a5e59250 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 061f316edc2..091a8aa2a9a 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 b40aa8726d3..2be2ab16c82 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 7679bef1b7a..e2d57047d34 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 6357f27c2d478ae6a313158c1cdd75f816cd73db Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 21 Feb 2020 12:24:09 +0100 Subject: [PATCH 100/710] 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 091a8aa2a9a..b060439b8d7 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 519a4e1389ccbaf56719832784265acf60eae3c3 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 21 Feb 2020 13:05:29 +0100 Subject: [PATCH 101/710] 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 835cbac492b..e0757705d35 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 b060439b8d7..6916a949437 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 16ac419a94759b528d1e58bae10773c238137421 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 24 Feb 2020 16:14:02 +0100 Subject: [PATCH 102/710] 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 e0757705d35..430bb30a042 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 6916a949437..287f0ae50c7 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 2d97ac0948e1fc3bcc605837c7eced7238446e65 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 24 Feb 2020 16:41:12 +0100 Subject: [PATCH 103/710] 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 b39ee610aa1..1cc7ee589a2 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 00000000000..d3b67bab630 --- /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 430bb30a042..9cb9b2804aa 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 287f0ae50c7..d6267314aa3 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 5b95163d328567208bf3b9b2788ec53e1c619b38 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 25 Feb 2020 09:33:09 +0100 Subject: [PATCH 104/710] 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 d3b67bab630..d3e9997ac03 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 9cb9b2804aa..1736ee711fa 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 d6267314aa3..74879c0b302 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 7be7252e6275f9e8bbb56a7f3faf7a8345727404 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 25 Feb 2020 09:48:12 +0100 Subject: [PATCH 105/710] 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 1736ee711fa..e2967065e6d 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 868279b9677ecab18172c98b7dc52d3f346de545 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 25 Feb 2020 10:45:13 +0100 Subject: [PATCH 106/710] 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 e2967065e6d..71acc8bd03b 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 74879c0b302..463c9951d8e 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 683b36c9c57f0c36ea0e9d6d7d8431b2bf4a6745 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 25 Feb 2020 11:01:57 +0100 Subject: [PATCH 107/710] 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 00000000000..4adf2c25123 --- /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 252113fe8cf6373646fd5a74fc38de4c19b7fc7f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 25 Feb 2020 11:04:01 +0100 Subject: [PATCH 108/710] 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 d3e9997ac03..b829cb8d851 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 2b5b0cbeceb1437f047cdcde06203396c3309acc Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 25 Feb 2020 09:40:47 +0100 Subject: [PATCH 109/710] 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 71acc8bd03b..dc4ab7078d3 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 463c9951d8e..0851b14c044 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 0d4c608b6947c5fd24efe7bd8525e6b212413d15 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 25 Feb 2020 15:30:08 +0100 Subject: [PATCH 110/710] 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 1cc7ee589a2..e9f59c02e39 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 00000000000..63cbf4ab8cf --- /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 dc4ab7078d3..95a399d73c6 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 0851b14c044..7547df2f461 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 25171890d6775cb353b758e4ccdf607953bf1b90 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 25 Feb 2020 16:14:19 +0100 Subject: [PATCH 111/710] 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 95a399d73c6..4edaf8bb269 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 7547df2f461..8609c989da5 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 103dd159066e109563607854d63d99ce9f7cab43 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 25 Feb 2020 17:01:47 +0100 Subject: [PATCH 112/710] 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 4edaf8bb269..f3400b21eaf 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 8609c989da5..e9a5674a0d6 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 3f3211d27851c5b11969ea9a98bef44850ba739d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Feb 2020 10:31:37 +0100 Subject: [PATCH 113/710] 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 e4331331dd9..8e179399c0e 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 e9a5674a0d6..00000000000 --- 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 00000000000..02574d89b40 --- /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 00000000000..38f0b7a40be --- /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 00000000000..f8e8b0d5dc7 --- /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 00000000000..a2d83d1a6c8 --- /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 00000000000..5fc13b31371 --- /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 141d42eb2159f343c4505e06e49e507cdc75fdf8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 27 Feb 2020 10:54:42 +0100 Subject: [PATCH 114/710] 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 f3400b21eaf..3675796e949 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 69b2ff8af95f7af20062ba404d46fb7de2a7cb44 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 27 Feb 2020 11:15:15 +0100 Subject: [PATCH 115/710] 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 b48d72b8198..ba49f92715b 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 00000000000..58d2869cb45 --- /dev/null +++ b/shopfloor/views/stock_location.xml @@ -0,0 +1,15 @@ + + + + Shopfloor stock.location form + stock.location + + + + + + + + From ffabc23f3c762ec69740d146681e058a6ff673e8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 08:58:26 +0100 Subject: [PATCH 116/710] 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 67b2b16b385..f4e1e3373cb 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 42051cdfa3c3eed019a2e7e603494ffca6be7f7d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:01:53 +0100 Subject: [PATCH 117/710] 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 448e54c3155..49612a4ac00 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 3675796e949..13894d40f55 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 bd70fac7d09e7f88a6032286f6197fce632e98da Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:07:22 +0100 Subject: [PATCH 118/710] 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 13894d40f55..f3340991e39 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 18060c8f8b4b390afcb648276f86946ce51104a6 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:11:40 +0100 Subject: [PATCH 119/710] 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 f3340991e39..3d18bb2c08c 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 93d810fc05d0e50b4aa97b20a50992fba909852c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:13:06 +0100 Subject: [PATCH 120/710] 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 3d18bb2c08c..bb2e60a4f56 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 cb77f5d0fe6cad65cba2a8fe5bcadbae51d7e20c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:13:50 +0100 Subject: [PATCH 121/710] 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 bb2e60a4f56..57e8b73f989 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 5a6a37c1e5908522ae2a11d8b9b331e964e29b3c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:48:33 +0100 Subject: [PATCH 122/710] 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 57e8b73f989..40dea0b9da9 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 a320e02ebef94577202d5fd703517219c7e25396 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 09:49:42 +0100 Subject: [PATCH 123/710] 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 40dea0b9da9..a1a61d6593f 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 324961bb02a0fec524fe6c6b57ea60f6eb4bb2b8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 14:47:48 +0100 Subject: [PATCH 124/710] 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 a1a61d6593f..b5b33ff2c2c 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 94059ad25c8b723c08cef9ddb24dfc13c9534faa Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Tue, 25 Feb 2020 11:54:49 +0100 Subject: [PATCH 125/710] Fix confirmation location changed on PutAway --- shopfloor/actions/message.py | 7 +++++++ shopfloor/services/single_pack_putaway.py | 6 ++++-- shopfloor/tests/test_single_pack_putaway.py | 17 ++++++++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 49612a4ac00..5bee87dbeff 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 c694aadc0af..a1887936a21 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -286,7 +286,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() @@ -367,7 +369,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 bd759fbbbcd..41bddad74a7 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -393,6 +393,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( @@ -402,11 +403,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 c4a88a53631cc6bf09895fa4541e97e64603326f Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Wed, 26 Feb 2020 09:38:47 +0100 Subject: [PATCH 126/710] 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 550ee27ec8b..30120c4fd21 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 e2d57047d34..c6bada016be 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 44125d4c87ef11d70dee787df038ff2783cb713d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 15:06:17 +0100 Subject: [PATCH 127/710] 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 b5b33ff2c2c..b1e34382ee2 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 30120c4fd21..01f5c8b5f01 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 ed496ce6bf357c687480d7e6c0664979767fcbac Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Feb 2020 15:08:39 +0100 Subject: [PATCH 128/710] 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 58d2869cb45..3bb76f98849 100644 --- a/shopfloor/views/stock_location.xml +++ b/shopfloor/views/stock_location.xml @@ -1,6 +1,6 @@ - + Shopfloor stock.location form stock.location From 7a30884e9bfaa975b334c3d2faf8d59b95857228 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 2 Mar 2020 13:46:55 +0100 Subject: [PATCH 129/710] 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 | 2 +- shopfloor/tests/test_single_pack_transfer.py | 11 +++-------- 7 files changed, 16 insertions(+), 22 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index b1e34382ee2..20e076ac8ce 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 02574d89b40..0756b05fef5 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 38f0b7a40be..1b258273cc8 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 f8e8b0d5dc7..91cf8379b5b 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 5fc13b31371..ed522c66dc8 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 41bddad74a7..989b695ac47 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -463,7 +463,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 c6bada016be..c4c0a69cb0f 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 7e58b7046873a09e8608588a57d6502c9a5135cc Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 2 Mar 2020 14:27:06 +0100 Subject: [PATCH 130/710] 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 20e076ac8ce..05d60d76b68 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 0756b05fef5..bf97800b413 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 91cf8379b5b..63b05e04e78 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 bbc242ec384cb9a06273f2099ccec2092f7d10d5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 2 Mar 2020 16:07:36 +0100 Subject: [PATCH 131/710] 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 b21559b81ad..24a27b0f2a0 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -147,9 +147,7 @@ def _response_schema(self, data_schema=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 @@ -169,7 +167,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 517f6b49aec339d6d1425ecb24f7346bf217ab60 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 4 Mar 2020 11:47:10 +0100 Subject: [PATCH 132/710] 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 5bee87dbeff..effb2b58920 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 05d60d76b68..6f3ba361d5c 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 ed522c66dc8..a801367eed8 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 f744fc1d8606873c5cecf2ccb4a71920d16f2d00 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 4 Mar 2020 12:51:18 +0100 Subject: [PATCH 133/710] 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 989b695ac47..09138a5b0c3 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.assertDictEqual( # response, From 22f23928989653b2c678b7a433547de213712370 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 5 Mar 2020 10:11:30 +0100 Subject: [PATCH 134/710] 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 effb2b58920..b6bde733ad9 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 63cbf4ab8cf..d41c531f2f9 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 6f3ba361d5c..69d89145672 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 bf97800b413..f44f3b8a80b 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 1b258273cc8..3e6d212d001 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 a801367eed8..5668308fe22 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 c437d0862d6556b542371f3631cac5e1806b37ea Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 5 Mar 2020 16:04:53 +0100 Subject: [PATCH 135/710] 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 a6719c5d2f9..c9bc8c3b8e3 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 00000000000..7feeb36d449 --- /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 b6bde733ad9..f338c466589 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 b829cb8d851..f205aeaa01f 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 69d89145672..88b0f8e15a6 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 8f4a5e59250..fe4dbeeba4e 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 3e6d212d001..93682b07ffb 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 5108f8be1a2ecc7249e0efae66a0b7c9676901ed Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 5 Mar 2020 16:36:19 +0100 Subject: [PATCH 136/710] 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 | 2 +- shopfloor/services/single_pack_transfer.py | 10 ++-- shopfloor/services/validator.py | 16 ++++++ 5 files changed, 57 insertions(+), 55 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 6e2c95d8b4e..77e5acea3e2 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 88b0f8e15a6..e0fd21f2078 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 a1887936a21..28dab77cdfd 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -373,7 +373,7 @@ def _states(self): } def cancel(self): - return self._response_schema(next_states=["start"]) + return self._response_schema(next_states={"start"}) def _validator_scan_pack(self): return {"barcode": {"type": "string", "nullable": False, "required": True}} diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 01f5c8b5f01..edef7da3b14 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 f4e1e3373cb..b7db6913619 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 030959c592448888f81ac2ae1f259d7dd0b7f220 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 6 Mar 2020 16:22:19 +0100 Subject: [PATCH 137/710] 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 63b05e04e78..af742af0335 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 c7f511aa230853a9d6a714219686a69cf884854b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 11 Mar 2020 15:26:21 +0100 Subject: [PATCH 138/710] 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 c9bc8c3b8e3..b98bf56e071 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 00000000000..ee2b9039507 --- /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 6a8054d8e04..34f9243f4fc 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 77e5acea3e2..bb2d2f40837 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 00000000000..8d05bd4f020 --- /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 b7db6913619..db6d3bb3dfd 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 474cc32a350d1521564376e297339d0c2e156cff Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 12 Mar 2020 13:06:20 +0100 Subject: [PATCH 139/710] 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 f19f603dfbf..84e8e4f0c43 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -18,4 +18,10 @@ + + Checkout + 30 + + + diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml index 660079c145b..844ec09df6b 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 653cac77592..079bf0fb83e 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 bff78f93e3b..1492df892d6 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 bb2d2f40837..0e7eba6bb78 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 8d05bd4f020..5e58b1ffca7 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 7cd5133cdd9..2c6d840befd 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 464ee95ce6969f793aa0cfa7445212e2a0ad29cc Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 12 Mar 2020 16:10:53 +0100 Subject: [PATCH 140/710] 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 fe4dbeeba4e..aa11815b480 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 192366f2e3e1dae625c58c0fc6c9bbc8915f201f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 12 Mar 2020 16:55:35 +0100 Subject: [PATCH 141/710] 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 ba49f92715b..cc03632c6a3 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 706b6c057ee..3878d0b8c58 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 0e7eba6bb78..ed8b944a646 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 5e58b1ffca7..0bf392d65c4 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 2c6d840befd..52cb06c5298 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 1ff9392e6b48e0dae53c5dd86752ed90c7b633c7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 13 Mar 2020 12:19:50 +0100 Subject: [PATCH 142/710] 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 f338c466589..8c5e519ee2a 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 6ff8d3a4e8c..4e391e629d1 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 ed8b944a646..48447c8639e 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 8e179399c0e..598176820c5 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 52cb06c5298..00000000000 --- 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 00000000000..540deed8011 --- /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 00000000000..f87ff580f84 --- /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 9c78dfb8acc22fc091a8632d6f3672fd18f939fd Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 13 Mar 2020 14:08:01 +0100 Subject: [PATCH 143/710] 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 48447c8639e..b6f1bd5b780 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 598176820c5..693366f0495 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 540deed8011..1c5848ce4a3 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 f87ff580f84..98a1e6d2f4a 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 00000000000..21fab504a86 --- /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 9d92c716c73a876bfccf319f6a00a007f888e23a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 13 Mar 2020 16:04:13 +0100 Subject: [PATCH 144/710] 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 b6f1bd5b780..e53768970ea 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 4ccc97d4d3edcc5c0f677c16ebb308291f4d9ec4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 13 Mar 2020 16:04:59 +0100 Subject: [PATCH 145/710] 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 e53768970ea..22aa97c3c38 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 21fab504a86..d860e45c3b7 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 ec0f8d1b012cf76e88b799bd3bf7c7500927fa73 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 16 Mar 2020 11:45:37 +0100 Subject: [PATCH 146/710] 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 8c5e519ee2a..1e9f416b920 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 22aa97c3c38..94ba08b2a4f 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 1c5848ce4a3..77accd1a2d7 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 98a1e6d2f4a..017ddf57e13 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 d860e45c3b7..bc37d3fc2c8 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 e146d63cc17ba240488bee0db8e18756869c97b9 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 16 Mar 2020 15:33:14 +0100 Subject: [PATCH 147/710] 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 1e9f416b920..63434a6b5d0 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 3878d0b8c58..c170963b1d4 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 94ba08b2a4f..76957097e88 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 0bf392d65c4..63bede3dadb 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 693366f0495..e002c1d6cb8 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 e431ba588b8..ff29c176a37 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 77accd1a2d7..2e489405c6b 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 017ddf57e13..43ba2cc6dd2 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 00000000000..d0adae6322b --- /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 bc37d3fc2c8..8393a9763c8 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 98952c5c2e2..685850002bb 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 d8ef41fe0a052d232521c9dd14a5b854f0906e77 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 17 Mar 2020 14:55:30 +0100 Subject: [PATCH 148/710] 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 63434a6b5d0..2891e25c0d1 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 f205aeaa01f..0d772accc97 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 76957097e88..b19fb4ab1ef 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 aa11815b480..2ae7f007eef 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 2e489405c6b..e3bc09a5a0f 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 d0adae6322b..ea1ad70cae7 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 2e317fb50c4bc2446efa00fa1d25046467044965 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 18 Mar 2020 11:13:37 +0100 Subject: [PATCH 149/710] 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 2891e25c0d1..b2b6cc84336 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 b19fb4ab1ef..ce3ff645f60 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 e002c1d6cb8..cf1bf3cd8ec 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 e3bc09a5a0f..a3709d93298 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 ea1ad70cae7..b645e97d1a8 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 00000000000..22066b1384c --- /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 af742af0335..5bc64cd8163 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 3932f1dae9cb17366e80f1eae1df4ea89f564198 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 18 Mar 2020 14:28:59 +0100 Subject: [PATCH 150/710] 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 ce3ff645f60..bf75100b586 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 cf1bf3cd8ec..77713a88a20 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 00000000000..35bc8b7819f --- /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 06e7168c84d64f0a5566c4e473bb35a6b4081d7c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 20 Mar 2020 11:14:50 +0100 Subject: [PATCH 151/710] 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 c170963b1d4..a547abbbc6a 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 bf75100b586..b7fc8c3e750 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 77713a88a20..08f255a73e3 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 a3709d93298..0ad543068e9 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 b645e97d1a8..4344b8f8a91 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 00000000000..9aa13fd6e38 --- /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 22066b1384c..5db7c188a1a 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 00000000000..4a66c075e4e --- /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 35bc8b7819f..e2df7952a34 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 00000000000..c58803c11c3 --- /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 7e4f75579f9ea92a4b6b3bf4b1f0b5dd950839e1 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 20 Mar 2020 11:30:48 +0100 Subject: [PATCH 152/710] 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 b7fc8c3e750..916c779d08f 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 08f255a73e3..920e2190e5e 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 00000000000..bd62d52c49e --- /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 3ef656040d7fec8f29414e38303bcb595e548be9 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 20 Mar 2020 11:40:16 +0100 Subject: [PATCH 153/710] 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 916c779d08f..2a72de2dd44 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 a44a5b0bcb1e39a2032cbeba71560e6e62c3d6d6 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 20 Mar 2020 15:07:36 +0100 Subject: [PATCH 154/710] 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 800aec5f0b8..7858d1d0442 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 84e8e4f0c43..8d324b87624 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -1,27 +1,22 @@ - Put-Away Reach Truck 10 - + - Single Pallet Transfer 20 - + - Cluster Picking 30 - + - Checkout 30 - + - diff --git a/shopfloor/demo/shopfloor_operation_group_demo.xml b/shopfloor/demo/shopfloor_operation_group_demo.xml index 4f8104efb52..8d549c16485 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 844ec09df6b..e16c9ff92ee 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 5ef9df4179d..743890335cd 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 079bf0fb83e..1fdf6ae096e 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 71f19f9f78f..6ece0c498f3 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 016b872f25a..d34c291c346 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -1,29 +1,26 @@ - + - shopfloor menu tree shopfloor.menu - - - - + + + + - shopfloor menu search shopfloor.menu - + - Menus shopfloor.menu diff --git a/shopfloor/views/shopfloor_operation_group.xml b/shopfloor/views/shopfloor_operation_group.xml index 28fdc3ecc36..d4286df2239 100644 --- a/shopfloor/views/shopfloor_operation_group.xml +++ b/shopfloor/views/shopfloor_operation_group.xml @@ -1,16 +1,15 @@ - + shopfloor operation group tree shopfloor.operation.group - - + + - shopfloor operation group form shopfloor.operation.group @@ -19,33 +18,30 @@ - + - - + + - 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 index 25821c60fc4..5ec7e2fb589 100644 --- a/shopfloor/views/shopfloor_process.xml +++ b/shopfloor/views/shopfloor_process.xml @@ -1,17 +1,16 @@ - + shopfloor process tree shopfloor.process - - - + + + - shopfloor process form shopfloor.process @@ -20,35 +19,32 @@ - - + + - + - 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 26720fae2bb..5f0fd5245b7 100644 --- a/shopfloor/views/shopfloor_profile_views.xml +++ b/shopfloor/views/shopfloor_profile_views.xml @@ -1,18 +1,17 @@ - + shopfloor.profile tree shopfloor.profile - - + + - + - shopfloor.profile form shopfloor.profile @@ -21,37 +20,34 @@ - + - - - + + + - shopfloor.profile search shopfloor.profile - - - - + + + + - Profiles shopfloor.profile ir.actions.act_window tree,form - diff --git a/shopfloor/views/stock_location.xml b/shopfloor/views/stock_location.xml index 3bb76f98849..b3afe523eeb 100644 --- a/shopfloor/views/stock_location.xml +++ b/shopfloor/views/stock_location.xml @@ -1,15 +1,15 @@ - + - - Shopfloor stock.location form - stock.location - - - - - - - + + Shopfloor stock.location form + stock.location + + + + + + + diff --git a/shopfloor/views/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml index c5ba2996ffa..7d38e3074f8 100644 --- a/shopfloor/views/stock_picking_type.xml +++ b/shopfloor/views/stock_picking_type.xml @@ -1,9 +1,9 @@ - + Operation Types stock.picking.type - + From 73d0042c6b62c42d1fcc50e332bec7c40161d3ad Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 24 Mar 2020 10:02:43 +0100 Subject: [PATCH 155/710] Add skeleton for delivery service --- shopfloor/demo/shopfloor_menu_demo.xml | 7 +++- shopfloor/demo/shopfloor_process_demo.xml | 5 +++ shopfloor/demo/stock_picking_type_demo.xml | 15 ++++++++ shopfloor/models/shopfloor_process.py | 1 + shopfloor/services/__init__.py | 1 + shopfloor/services/delivery.py | 44 ++++++++++++++++++++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_delivery_base.py | 25 ++++++++++++ 8 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 shopfloor/services/delivery.py create mode 100644 shopfloor/tests/test_delivery_base.py diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 8d324b87624..eaf4bb73e0d 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -16,7 +16,12 @@ Checkout - 30 + 40 + + Delivery + 50 + + diff --git a/shopfloor/demo/shopfloor_process_demo.xml b/shopfloor/demo/shopfloor_process_demo.xml index e16c9ff92ee..0db0a727e4f 100644 --- a/shopfloor/demo/shopfloor_process_demo.xml +++ b/shopfloor/demo/shopfloor_process_demo.xml @@ -19,4 +19,9 @@ checkout + + Delivery + delivery + + diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index 1fdf6ae096e..ded6d31b777 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 1492df892d6..2eafef947c3 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 34f9243f4fc..8908b1cb7cb 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 00000000000..48ad6ff6681 --- /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 920e2190e5e..9136abf5bb2 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 00000000000..80c8bc0a914 --- /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 8891b9c4bc5b12288ea8a4d49f3315a0c84a24ac Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 24 Mar 2020 10:17:19 +0100 Subject: [PATCH 156/710] 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 9136abf5bb2..b01b5ef3549 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 ff29c176a37..f64bb0f91e2 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 0ad543068e9..3e55e9ae851 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 f44f3b8a80b..3d52804aff4 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 80c8bc0a914..27d645a703e 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 685850002bb..a1a86ca74db 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 00000000000..5eff2b327c8 --- /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 2be2ab16c82..bf76635a823 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 7a2fc1f4e09..a3eb4489a51 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 09138a5b0c3..cedc0c4711b 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 c4c0a69cb0f..c5c49c4261b 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 7dd16fcc5273977d11f9be7dde9ccd65beeaa548 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 24 Mar 2020 14:41:22 +0100 Subject: [PATCH 157/710] 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 48ad6ff6681..6db8933d8d2 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 d7ef0dfc6e8c1a39f941a555b95fe4dece6407cd Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Mar 2020 12:42:46 +0100 Subject: [PATCH 158/710] 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 e0fd21f2078..19315124868 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 6cebcb4b1bcdbcbec112a8323d9368f79cccc125 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Mar 2020 14:00:48 +0100 Subject: [PATCH 159/710] 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 19315124868..a81634f37dc 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 93682b07ffb..51921346618 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 1c64e1785669e68af77a0a4d104e53cdb181f06f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 25 Mar 2020 14:35:09 +0100 Subject: [PATCH 160/710] 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 2a72de2dd44..ab14dd3294e 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 e2df7952a34..d6a91c90282 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 a80de40517f2363489c6704084b0fcab9fb16d03 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 26 Mar 2020 08:07:28 +0100 Subject: [PATCH 161/710] 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 ab14dd3294e..3f5cd577d51 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 b01b5ef3549..709fa5cd766 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 3e55e9ae851..8053ee6ace9 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 00000000000..2b70308bce2 --- /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 172cc2eb64a8ab219425cb2ff1e4ffc7f44a20be Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 26 Mar 2020 15:22:14 +0100 Subject: [PATCH 162/710] 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 3f5cd577d51..108e96df7c7 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 2b70308bce2..dcbd7a59c9b 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 cbbab3271be2c993cbc7db568d57e542673914ce Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 27 Mar 2020 11:08:05 +0100 Subject: [PATCH 163/710] 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 cc03632c6a3..edba0364fa3 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 108e96df7c7..8f31b97385a 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 63bede3dadb..e4762d247bd 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 709fa5cd766..d1fcb465563 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 00000000000..71c2883a37a --- /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 9aa13fd6e38..1af133148d9 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 ec89ec370290da08a53789adc407abd292bb501f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 27 Mar 2020 11:47:46 +0100 Subject: [PATCH 164/710] 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 8f31b97385a..b3d2edf65cf 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 71c2883a37a..929809f5f76 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 bd0339245054822e6b5afe9236314959a46bb238 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 27 Mar 2020 13:25:11 +0100 Subject: [PATCH 165/710] 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 b3d2edf65cf..9489223fefa 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 d1fcb465563..408731f3bf7 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 00000000000..9a2d4071180 --- /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 594a399b5aaffdd5e8abb1c17ec5a56e81e8fe6e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 27 Mar 2020 14:24:59 +0100 Subject: [PATCH 166/710] 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 9489223fefa..31c5a111b11 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 408731f3bf7..4f4fe711724 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 00000000000..373fb2727e1 --- /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 99b7731af406215dc4ba5178e33f714f532a8c85 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 30 Mar 2020 13:34:12 +0200 Subject: [PATCH 167/710] 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 ee2b9039507..9cab2186c69 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 31c5a111b11..f30383c6ff1 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 4f4fe711724..8911b00f829 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 2ae7f007eef..33420452452 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 00000000000..c8f2207b9e7 --- /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 8053ee6ace9..cbf348b2466 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 929809f5f76..d532830a1bd 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 dcbd7a59c9b..e008422ac25 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 8393a9763c8..c8e30df13ad 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 4a66c075e4e..e82ac9f9afe 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 3d52804aff4..62c1aa6550d 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 52e5a734a1832de9ea2f5318e4bb79496e32c6ee Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 30 Mar 2020 14:26:08 +0200 Subject: [PATCH 168/710] 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 f30383c6ff1..da34175d40b 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 123f0b957b634c93da7719948cececf6fab336e8 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 30 Mar 2020 14:29:12 +0200 Subject: [PATCH 169/710] 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 da34175d40b..26e12ab0c72 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 4344b8f8a91..64401d051d6 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 5db7c188a1a..ef033ee1ceb 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 8607c27aeaeabd359cd57927fbd625cd89a0be7a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 1 Apr 2020 11:44:26 +0200 Subject: [PATCH 170/710] 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 6db8933d8d2..3a33be7b3fb 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 6d0f1fa9ae4da4b798ea5d54e1d35cf0e9d938ed Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 1 Apr 2020 11:55:13 +0200 Subject: [PATCH 171/710] 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 26e12ab0c72..bb9db670c7f 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 ae7b54132089aa4d0fc49776ffdb0832bdb8bc79 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 10 Apr 2020 14:51:50 +0200 Subject: [PATCH 172/710] 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 a81634f37dc..41da540a84b 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 1d2b7671b566fbb624fffe30aa33bee65282c9aa Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 24 Apr 2020 13:33:22 +0200 Subject: [PATCH 173/710] 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 bb9db670c7f..76acbfae7cd 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 f1f5f5f8a93b87fa1bce00b3b478e17cdfab2726 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 24 Apr 2020 09:45:42 +0200 Subject: [PATCH 174/710] 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 76acbfae7cd..883ea54efb8 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 f2c4265753cb0fa78a01c70337695c50a66cb4b5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 10:11:54 +0200 Subject: [PATCH 175/710] 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 883ea54efb8..6b8080d5105 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 8911b00f829..87e4ace1c22 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 00000000000..9cdfd567c9a --- /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 cadab8cba552800728a4c73aac549be94a8896b3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 11:09:58 +0200 Subject: [PATCH 176/710] 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 0d772accc97..0ee951644fb 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 6b8080d5105..79cd7470a41 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 cbf348b2466..1f09b9d0a37 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 d532830a1bd..3d06fc48ad2 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 373fb2727e1..f47db4fd6c1 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 e008422ac25..a17bcb9d918 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 bd62d52c49e..ab720ce5ae5 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 9cdfd567c9a..2da2f732a6f 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 9a2d4071180..09c8e739761 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 64401d051d6..8163bcdfcdb 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 1af133148d9..0c293e308f7 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 ef033ee1ceb..800daf56de1 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 c58803c11c3..9b0149335dc 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 31f5a4b1e6941b4f9d3898d4d4d8dc952b3c009c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 12:12:25 +0200 Subject: [PATCH 177/710] 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 79cd7470a41..e8c59d4c087 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 87e4ace1c22..baa5b40e967 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 09c8e739761..76f5cda812e 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 50193a77ba7297b2aaa68225c068669eae6cf35c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 16:24:18 +0200 Subject: [PATCH 178/710] 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 e8c59d4c087..9746e2bf7d0 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 2e11c046e5ca8ba69ede355d7fbdcaa7152634eb Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 16:25:42 +0200 Subject: [PATCH 179/710] 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 9746e2bf7d0..236356bd5a9 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 d817e4502b4811551c3a554df8d1cce54ef3e867 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 29 Apr 2020 16:39:03 +0200 Subject: [PATCH 180/710] 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 | 2 +- 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 | 4 +-- shopfloor/tests/test_single_pack_transfer.py | 6 ++-- 9 files changed, 31 insertions(+), 31 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 41da540a84b..0ec6fd0ee49 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 28dab77cdfd..2f1121a8023 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -395,7 +395,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 edef7da3b14..d2db50456fb 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 62c1aa6550d..7463a7e0b29 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 51921346618..181b9e0e150 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 5bc64cd8163..dfc89c6c2ae 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 5668308fe22..34de5253ade 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 cedc0c4711b..fc7cf893cb7 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -209,7 +209,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}, @@ -413,7 +413,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 c5c49c4261b..5a916595029 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 617264c5a9d83c7a4fe05456abcff769c0dbf71d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 30 Apr 2020 15:46:18 +0200 Subject: [PATCH 181/710] 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 87ce6ef967e..c5330310e99 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 997c33eda4c..0a87f0b6e35 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 140c3bfde39..d516f9e0299 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 f64bb0f91e2..4c6039ce1b7 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 8a089124ed99138d7f7254d74369b792abb2a77f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 4 May 2020 13:13:06 +0200 Subject: [PATCH 182/710] 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 236356bd5a9..16049021969 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 77a95a6321464c73a76f6455f7805c40ac77f2b7 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 4 May 2020 16:01:22 +0200 Subject: [PATCH 183/710] 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 d516f9e0299..839d5634f31 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 4c6039ce1b7..c3cab5356a6 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 e0a53abae88c49129b637d72cbeaf82f7571aa7e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 4 May 2020 16:14:53 +0200 Subject: [PATCH 184/710] 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 edba0364fa3..2342b041d13 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 00000000000..3945954cdba --- /dev/null +++ b/shopfloor/views/stock_move_line.xml @@ -0,0 +1,34 @@ + + + + shopfloor stock_move_line_detailed_operation_tree + stock.move.line + + + + + + + + + + + From 235dc02e98db9a5288e82629a53137e76c1046d2 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 4 May 2020 09:55:12 +0200 Subject: [PATCH 185/710] 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 d41c531f2f9..c835935fb3c 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 24a27b0f2a0..97326d40508 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 3e0e431d26c10e565601577e6336e3d2c7923dfe Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 5 May 2020 09:40:16 +0200 Subject: [PATCH 186/710] 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 4101de4da59..c3f3173b034 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 0ec6fd0ee49..5de76b4244e 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 2838373dc37..de5086ddf01 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 77b7804d01d6fa01b7b954a2b4d2315358266f77 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 5 May 2020 17:51:52 +0200 Subject: [PATCH 187/710] 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 a547abbbc6a..2bba15131d9 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 16049021969..3d04bc72b87 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 76f5cda812e..cbe9aee027d 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 3d06fc48ad2..ea1c4c6e741 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 f47db4fd6c1..2ddb794c7ca 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 43ba2cc6dd2..4972a278cd0 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 8163bcdfcdb..720bfb9973d 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 0c293e308f7..b9d5b870b1a 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 800daf56de1..c327ab989a0 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 9b0149335dc..2d765930a80 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 dfc89c6c2ae..b844873408d 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 a1a86ca74db..e7e93cc59af 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 bf76635a823..a68d6e90746 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 8815b0f2eb7abf6723f3324e75b0033aadf58a01 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 27 Apr 2020 11:16:44 +0200 Subject: [PATCH 188/710] 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 2342b041d13..bfb02438c88 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 eaf4bb73e0d..1276f19a987 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -2,26 +2,46 @@ Put-Away Reach Truck 10 - + single_pack_putaway + Single Pallet Transfer 20 - + single_pack_transfer + Cluster Picking 30 - + cluster_picking + Checkout 40 - + checkout + Delivery 50 - + delivery + diff --git a/shopfloor/demo/shopfloor_operation_group_demo.xml b/shopfloor/demo/shopfloor_operation_group_demo.xml deleted file mode 100644 index 8d549c16485..00000000000 --- 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 0db0a727e4f..00000000000 --- 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 743890335cd..0275b3e335f 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 e9f59c02e39..d86b15fec59 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 8c136f725e7..00000000000 --- 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 0f0a56e2bbd..c5058c1f569 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 0dc47fe272a..00000000000 --- 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 2eafef947c3..00000000000 --- 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 f18d813b31b..ec9e66c7921 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 0ee951644fb..ce6d28630d0 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 80374d89b9b..f974b0257a1 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 41e5dd07c56..6aca8c733c7 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 3d04bc72b87..b5d7dafda9b 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 5de76b4244e..842e08d4141 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 839d5634f31..b0322fd4e4e 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 de5086ddf01..e86fcf0e2d6 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 94ee11d5ed6..af9b9d28411 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 97326d40508..c245878cf3d 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): @@ -153,10 +150,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 2f1121a8023..e33f1595157 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -65,7 +65,7 @@ def _response_for_forbidden_scan_pack(self, existing_operations): "title": _("Cannot proceed"), "body": _( "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 c3cab5356a6..dcfada2fb70 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 1f09b9d0a37..16a2123148d 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 c327ab989a0..8e6ecc69bc2 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 7463a7e0b29..aaa3be424a5 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 27d645a703e..564960a4c44 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 e7e93cc59af..45cc68e47c7 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 5eff2b327c8..1eae2f55696 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 a68d6e90746..0eb20edc822 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 fc7cf893cb7..804f4ba961c 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 @@ -128,7 +127,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, ), }, ) @@ -167,7 +166,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,), }, ) @@ -222,7 +221,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 5a916595029..62701632249 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 6ece0c498f3..29876a3a776 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 d4286df2239..00000000000 --- 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 5ec7e2fb589..00000000000 --- 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 5f0fd5245b7..785aa6231d1 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 7d38e3074f8..25313e15862 100644 --- a/shopfloor/views/stock_picking_type.xml +++ b/shopfloor/views/stock_picking_type.xml @@ -6,7 +6,7 @@ - + From 1b0adca4dd095c9bfeed538b959752b1ff171248 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 5 May 2020 15:04:02 +0200 Subject: [PATCH 189/710] 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 2bba15131d9..a547abbbc6a 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 c835935fb3c..4716a95bb10 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 b5d7dafda9b..265bba4dec2 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 4972a278cd0..43ba2cc6dd2 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 cdd736dedce4abb915b171b6cc801331bd60ea00 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 5 May 2020 15:51:03 +0200 Subject: [PATCH 190/710] 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 b2b6cc84336..d7166ece3e2 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 4e391e629d1..6c9ce5962ae 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 265bba4dec2..c48b5b9b708 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 b0322fd4e4e..a54a62615b3 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 e86fcf0e2d6..44de8eb7e81 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 c245878cf3d..b7ec9d9b1c9 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 e33f1595157..3c021d33a9a 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -129,8 +129,12 @@ def _response_for_scan_pack_success(self, move, pack): def scan_pack(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 d2db50456fb..2deccf5fc99 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 804f4ba961c..cd839fececa 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -124,7 +124,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 62701632249..7cb8ff577d5 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 ef7532642227da783c5e61a9629a37ec8800f1fa Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 5 May 2020 15:57:21 +0200 Subject: [PATCH 191/710] 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 d7166ece3e2..e035a3fdaa9 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 c5058c1f569..0074c72999c 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 c48b5b9b708..27ca05f3ab8 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 a54a62615b3..3c0321b23f8 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 b7ec9d9b1c9..01a15fc69f3 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 cbe9aee027d..5f0589c73c2 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 8e6ecc69bc2..c327ab989a0 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 81fb9b02fb777a273e453e03049938bc33905c6e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 6 May 2020 08:35:31 +0200 Subject: [PATCH 192/710] 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 3c0321b23f8..3d9bad9d988 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 45cc68e47c7..0c0f9ba6a8c 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 07fb1ccf1ef8fde75c93e542f9357b071c1c2f89 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 6 May 2020 12:29:37 +0200 Subject: [PATCH 193/710] 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 d86b15fec59..46d5a1ccf62 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 00000000000..d2a7e600426 --- /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 c3f3173b034..8a34d26c09e 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 4f993115b2837d103696a0a3e4ecc4c0344586d2 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 6 May 2020 12:33:36 +0200 Subject: [PATCH 194/710] 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 bfb02438c88..3e58a737932 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 9cab2186c69..79335a1636f 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 8908b1cb7cb..18e7b56db62 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 842e08d4141..f75e962e9ef 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 e4762d247bd..6ed975b0b5a 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 baa5b40e967..7994d4bf51c 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 0eb20edc822..114cd5354ae 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 1dec69cd0e5a126351f3ed2a9e41206692a5f380 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 6 May 2020 16:12:05 +0200 Subject: [PATCH 195/710] 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 79335a1636f..e71c9a5db90 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 27ca05f3ab8..ba3c0f42254 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 f75e962e9ef..1c5b767f16f 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 7bd237f4bd2..00000000000 --- 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 bf4d3ea2f39..00000000000 --- 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 6ed975b0b5a..2335aad540f 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 c8f2207b9e7..8adf3ba9bb4 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 16a2123148d..ce6a969d6be 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 a17bcb9d918..78af3be96ce 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 aaa3be424a5..7d8333bbfff 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 b844873408d..00ef30dbb2c 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 be1014abe3e858f4b0656a229640d3d56ea35b7c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 7 May 2020 09:09:17 +0200 Subject: [PATCH 196/710] 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 d2a7e600426..d266f38ccea 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 8a34d26c09e..e1f2093b60d 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 3fdbf1432e5e5c4936a668179521e6ba0d334a12 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 8 May 2020 09:45:03 +0200 Subject: [PATCH 197/710] 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 e035a3fdaa9..f7c56f85525 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 1c5b767f16f..c08d51abbf0 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 5ba22ec2eb0b1d3b5ecdeda5470632ff8f977d45 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 9 Mar 2020 12:49:16 +0100 Subject: [PATCH 198/710] 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 b98bf56e071..a5971aad7c9 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 00000000000..16cab4a0cf1 --- /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 e1f2093b60d..aff3ef2ecf5 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 c08d51abbf0..58abeccaeee 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 01a15fc69f3..03ee67180a3 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 _response_schema(self, data_schema=None): diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 2deccf5fc99..a2269954201 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 db6d3bb3dfd..353cfa0b105 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 33420452452..e1483551ef9 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 34de5253ade..337be573ae4 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 7cb8ff577d5..88c78b4c452 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 894d82d41e89910d0de2d18d188e39e3e896e922 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 9 Mar 2020 14:20:24 +0100 Subject: [PATCH 199/710] 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 58abeccaeee..94cab7bbeed 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 1ed1cdccca7262b314e7dc601699f799e108e982 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 7 May 2020 09:59:05 +0200 Subject: [PATCH 200/710] 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 7de53656cd4..7b80f0aae98 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 3c021d33a9a..d9e14ba676d 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 a2269954201..8c029fc589c 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 6383d583c59d6d384d2044bd0db40cac8f9d3b66 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 7 May 2020 15:20:39 +0200 Subject: [PATCH 201/710] 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 2335aad540f..762aa6d4c88 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 8c029fc589c..196298a846a 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 88c78b4c452..348baf34ae0 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 11af7456ba218312b9eab571444551d1b8ee5417 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 7 May 2020 18:09:21 +0200 Subject: [PATCH 202/710] 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 a547abbbc6a..9b2beca215f 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 18e7b56db62..7dee66d21c9 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 ba3c0f42254..cc0b18363ea 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 00000000000..410445fdb3c --- /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 762aa6d4c88..4bdd4a9fda9 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 8adf3ba9bb4..274f28289b1 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 00000000000..782b73cad6b --- /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 204a65f267d1998a2814674df06085da95ab49e8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 8 May 2020 10:20:36 +0200 Subject: [PATCH 203/710] 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 | 99 ++++++++++++---------- shopfloor/services/single_pack_putaway.py | 2 +- shopfloor/services/single_pack_transfer.py | 2 +- 7 files changed, 64 insertions(+), 48 deletions(-) diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py index 0a87f0b6e35..d9dcab6ef98 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 cc0b18363ea..e5afa78e247 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 94cab7bbeed..f3ba03b31a1 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 3a33be7b3fb..257910746f8 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 03ee67180a3..8a7aa8c0be9 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)]]) @@ -158,28 +141,27 @@ def _response_schema(self, data_schema=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) - - # 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", @@ -188,7 +170,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", @@ -197,9 +183,9 @@ def _get_openapi_default_parameters(self): "schema": {"type": "integer"}, "style": "simple", "value": profile.id, - }, - ] - ) + } + ), + defaults.extend(service_params) return defaults @property @@ -228,3 +214,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 d9e14ba676d..a9cfd913e91 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 196298a846a..fc7e9465fe8 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 6bd7b8d5528b3e3b4d9f4e06079097e546e224f9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 11 May 2020 07:55:02 +0200 Subject: [PATCH 204/710] 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 3e58a737932..f9e24629192 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 a5971aad7c9..58c70c29f36 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 e71c9a5db90..578f2ab5d75 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 00000000000..bb260db5443 --- /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 c5330310e99..3f04afa2409 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 6c9ce5962ae..cb3e8b8856e 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 4716a95bb10..41547158cff 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 7dee66d21c9..dccb05c8426 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 f3ba03b31a1..849faa01dad 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 410445fdb3c..9cfe8be26df 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 00000000000..435b108d5ee --- /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 7994d4bf51c..f6c6713ffde 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 274f28289b1..ca3678d3cb0 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 00000000000..f643419670e --- /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 782b73cad6b..c3af31cab5c 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 e8bee99eb92cf0a2d92478554e8f4cd9632c0e02 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 13 May 2020 11:22:45 +0200 Subject: [PATCH 205/710] 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 578f2ab5d75..977550d2ac4 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 4bdd4a9fda9..224db160ad4 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 e1483551ef9..f4ba02ca4c7 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 ca3678d3cb0..b25c08cd0b3 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 f643419670e..def85a49d11 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 b81eecddf93c751689a6e7473a80e103598f6e11 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 13 May 2020 12:03:39 +0200 Subject: [PATCH 206/710] 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 bb260db5443..2cd588e7f61 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 435b108d5ee..b28f069e7e2 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 def85a49d11..cbd2e71bcda 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 39ed13be2a861f9aff0569f1634bc12e267827a3 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 15 May 2020 08:00:11 +0200 Subject: [PATCH 207/710] 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 cb3e8b8856e..a1f1614a87d 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 42e22e8cb88154fa569cc8e143ce36e88d467f8e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 15 May 2020 13:03:49 +0200 Subject: [PATCH 208/710] 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/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 | 24 +++---- shopfloor/tests/test_single_pack_transfer.py | 34 +++++---- 24 files changed, 161 insertions(+), 178 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index f7c56f85525..7a6f099c84c 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 e5afa78e247..536e2465a3d 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 849faa01dad..edc7f6844e1 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 9cfe8be26df..38371070fd2 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/validator.py b/shopfloor/services/validator.py index 353cfa0b105..dd9a2d1fb33 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 f4ba02ca4c7..966207b53f7 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 5f0589c73c2..2dce5bca777 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 ea1c4c6e741..e88d312a181 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 2ddb794c7ca..8af94429730 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 78af3be96ce..2055f2041a2 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 ab720ce5ae5..46403ac9cb0 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 2da2f732a6f..052c8626c4e 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 43ba2cc6dd2..672bfec4035 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 720bfb9973d..e0063f515dd 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 b9d5b870b1a..dcc78ce607d 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 c8e30df13ad..c22c47cb932 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 c327ab989a0..dede5bfd372 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 d6a91c90282..39a6ee694fb 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 181b9e0e150..bf37e8767cc 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 00ef30dbb2c..14c52854039 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 337be573ae4..1205ec10b92 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 c3af31cab5c..81616439f4d 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 cd839fececa..445c4586a91 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -98,7 +98,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", }, ) @@ -124,7 +124,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, @@ -165,7 +165,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,), }, ) @@ -199,7 +199,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={ @@ -298,7 +298,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.", }, ) @@ -330,7 +330,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, ) @@ -365,7 +365,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, ) @@ -459,7 +459,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.", }, ) @@ -506,7 +506,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.", }, ) @@ -549,7 +549,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.", }, ) @@ -586,7 +586,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): @@ -602,6 +602,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 348baf34ae0..ec78610073e 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 dc996ded64d529f05f92ae91f4d0673351bed21f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 18 May 2020 08:36:34 +0200 Subject: [PATCH 209/710] 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 ec78610073e..510eaced85a 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 1b73f5d736e9c89f817ac4702f63385d82e83c41 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 26 Feb 2020 11:56:31 +0100 Subject: [PATCH 210/710] 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 7feeb36d449..a13eea5f0f4 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 edc7f6844e1..77ae1953e5f 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 f6c6713ffde..417f09e6ad0 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 7d8333bbfff..8ec69b8838a 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 00000000000..e2094a52bae --- /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 e94283ac209324b0f7527063805f5e2dca4bdcf7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 11 May 2020 16:53:11 +0200 Subject: [PATCH 211/710] 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 77ae1953e5f..561b002a2a1 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 bf37e8767cc..9cdd4a2ce67 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 e2094a52bae..deaa8204d9e 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 3868a34856a31cfa029e776d997d60759db8ad68 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 6 Mar 2020 12:44:18 +0100 Subject: [PATCH 212/710] 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 a13eea5f0f4..9f71fd2243f 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 7a6f099c84c..f565b5e8234 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 46d5a1ccf62..b44bcc12eb7 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 00000000000..1db120cc2f4 --- /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 561b002a2a1..6c7d980bd1c 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 417f09e6ad0..640b06ae81f 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 00000000000..5e5c77a5613 --- /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 027197021676aa734afb78e214d52ce2eb4aa2c9 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 14 May 2020 08:11:33 +0200 Subject: [PATCH 213/710] 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 6c7d980bd1c..2d288feca78 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 a2d83d1a6c8..2e78a3acc22 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 256095420f60462d9823750746b8583d617e8974 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 19 May 2020 16:38:09 +0200 Subject: [PATCH 214/710] 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 977550d2ac4..0d987e0da03 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 2cd588e7f61..b118e86e93e 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 224db160ad4..d97dbf36b69 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 b28f069e7e2..7736db7a8dc 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 966207b53f7..1cacd73b78e 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 b25c08cd0b3..4f44ad4dcd6 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 cbd2e71bcda..c208d522c92 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 d81afd99b911896a094259ba42eb6030f470e64b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 19 May 2020 16:44:42 +0200 Subject: [PATCH 215/710] 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 536e2465a3d..98db69b2dbf 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 2d288feca78..c0ba42fc973 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 257910746f8..9c59f4df349 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 fc7e9465fe8..adecaf1aed9 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 dd9a2d1fb33..c484c1514b4 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 ce6a969d6be..6dc4d8d8859 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 8ec69b8838a..bd551f0c80c 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 9cdd4a2ce67..3f54ab3984b 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 14c52854039..79e51fc58d4 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 deaa8204d9e..1c7c7b12fba 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 1205ec10b92..9d43419d80c 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 81616439f4d..87128304e00 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 da67848a219b6336c1a122a82e032ab2889fd63a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 19 May 2020 16:45:25 +0200 Subject: [PATCH 216/710] 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 f565b5e8234..aa68853d4a2 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 c0ba42fc973..632b802c864 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 9d43419d80c..2152b79d0c8 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 669b1b5953ffa1079cbbea0305203b32bec3199f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 20 May 2020 08:15:08 +0200 Subject: [PATCH 217/710] 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 632b802c864..45658011841 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 2e78a3acc22..8e1a004c10f 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 241e2f1d9ba3e215c90974f598c505ff4a9bc66a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 20 May 2020 08:57:40 +0200 Subject: [PATCH 218/710] 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 1cacd73b78e..db21e2a7913 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 c208d522c92..2ff4ccf67ed 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 bd551f0c80c..3453ede4280 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 941ca06b295883012bd7cffdef11997e2d05e7b8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 20 May 2020 09:00:07 +0200 Subject: [PATCH 219/710] 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 aff3ef2ecf5..07bf57a0990 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 45658011841..9653e9f743b 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 d97dbf36b69..78e85455208 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 4f44ad4dcd6..64f95e83992 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 3d89e1773d9ac0b7076ee030a4e3b64b58c62716 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 20 May 2020 17:29:24 +0200 Subject: [PATCH 220/710] 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 3945954cdba..5b970af6736 100644 --- a/shopfloor/views/stock_move_line.xml +++ b/shopfloor/views/stock_move_line.xml @@ -10,22 +10,20 @@ + From b42a0743e950e64dbbefaebc4dcbb80aacb496fb Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 22 May 2020 14:14:18 +0200 Subject: [PATCH 221/710] 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 98db69b2dbf..b00291df700 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 78e85455208..999c78c658b 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 e0fa48a1a36fceb442de5ecc672a61e38086a256 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 22 May 2020 14:14:46 +0200 Subject: [PATCH 222/710] 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 9653e9f743b..c72435ca724 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 a15a9cf1fdb18024d9a649d6b9394e5f52eea89a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 22 May 2020 14:59:21 +0200 Subject: [PATCH 223/710] 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 b00291df700..73cb1395d2a 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 8400b131618b5c7f2a976e5f9c7e86ef7a129a4d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 22 May 2020 15:46:21 +0200 Subject: [PATCH 224/710] 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 aa68853d4a2..30bc8e10fd2 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 c72435ca724..8ad83d8ac10 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 2dce5bca777..392a525ccc5 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 2055f2041a2..5a075d183a6 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 46403ac9cb0..7e62c0f3f06 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 052c8626c4e..f997ccad18c 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 dcc78ce607d..58edd46ded4 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 5e5c77a5613..ed0f38d3d31 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 445c4586a91..0c9e99d42aa 100644 --- a/shopfloor/tests/test_single_pack_putaway.py +++ b/shopfloor/tests/test_single_pack_putaway.py @@ -505,7 +505,7 @@ def test_cancel(self): response, next_state="start", message={ - "message_type": "info", + "message_type": "success", "body": "Canceled, you can scan a new pack.", }, ) @@ -548,7 +548,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 510eaced85a..bde20f97bc0 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 9e7b81d65d7e3977ba42046ab90db7193b72c102 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 26 May 2020 12:24:43 +0200 Subject: [PATCH 225/710] 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 9f71fd2243f..f195c05d118 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 b44bcc12eb7..7f9b9869655 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 00000000000..3581a18dce0 --- /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 00000000000..77c66673596 --- /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 8a7aa8c0be9..da1e2d3fb3a 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -140,7 +140,11 @@ def _response_schema(self, data_schema=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) + # 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 db21e2a7913..b83f076c0bb 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 64f95e83992..d0612fe1ee0 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 2ff4ccf67ed..6f3c85d62d1 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 dcfada2fb70..d45965861ec 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 6dc4d8d8859..25cbdc1dc51 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 392a525ccc5..108df0e03c3 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 e88d312a181..b7d41658378 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 8af94429730..bba6f7af586 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 5a075d183a6..e5d21d8fa39 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 58edd46ded4..c708ec47ea8 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 c22c47cb932..442ab0ae14c 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 dede5bfd372..c84d5bed9dd 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 39a6ee694fb..5440e473d4b 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 3453ede4280..2fd44ecb464 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 114cd5354ae..b7a2e915fcb 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 ed0f38d3d31..f809f19c8f7 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 3f54ab3984b..353125ffb2d 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 79e51fc58d4..1b86cdf6909 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 8e1a004c10f..41c612380ae 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 1c7c7b12fba..8a2de6b9213 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 2152b79d0c8..6528eafe12b 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 564960a4c44..a96ff008415 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 0c0f9ba6a8c..31ef0160426 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 1eae2f55696..913324c4a55 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 0c9e99d42aa..296546829f7 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 bde20f97bc0..bebde9dcc5a 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 9010bafe3eb1c0e9a6240ff14265d6f17f82cbb4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 26 May 2020 15:08:57 +0200 Subject: [PATCH 226/710] 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 8ad83d8ac10..881b56931e2 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 f809f19c8f7..f68772726b8 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 353125ffb2d..b67471d5600 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 41c612380ae..a0054f741ea 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 8a2de6b9213..8d1eaa0a83c 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 ca48b82f2ba40731c6536d83f806a5d6bb2a2cdd Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 26 May 2020 16:03:21 +0200 Subject: [PATCH 227/710] 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 999c78c658b..6e97fa35250 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 cf1ef297b0a8574f07ce9ea0dd0e8fb3eb846f02 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 26 May 2020 16:02:58 +0200 Subject: [PATCH 228/710] 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 5b970af6736..67ee61d0021 100644 --- a/shopfloor/views/stock_move_line.xml +++ b/shopfloor/views/stock_move_line.xml @@ -14,16 +14,19 @@ From cbe016b91ab2594b21403c5d61ce981986debf90 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 26 May 2020 11:34:50 +0200 Subject: [PATCH 229/710] 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 f9e24629192..7e0f0bddc7d 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 0d987e0da03..b16a6538fe8 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 6e97fa35250..868821378e5 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 b83f076c0bb..c5a933997bd 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 d0612fe1ee0..55fbb39b54b 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 ec2bd64c16ff143cc90057f8a605211f714c2233 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 27 May 2020 09:13:59 +0200 Subject: [PATCH 230/710] 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 c5a933997bd..f508b8b9bc9 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 c331c5406ca867cedc172b6c633b68019043d411 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 27 May 2020 09:34:05 +0200 Subject: [PATCH 231/710] 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 881b56931e2..05a485a5bf4 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 55fbb39b54b..f5482d075db 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 6f3c85d62d1..81be39f8825 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 2fd44ecb464..57ad3b68cc2 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 f68772726b8..37a6f6be644 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 b67471d5600..ec6494967b1 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 1b86cdf6909..e74b2722477 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 a0054f741ea..9ebfb12dac3 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 8d1eaa0a83c..ce08643e3e4 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 6528eafe12b..b91922591e5 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 8ad5efacf350545b1b3cc54623a2af034e9a73a9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 27 May 2020 12:53:47 +0200 Subject: [PATCH 232/710] 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 05a485a5bf4..881b56931e2 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 f5482d075db..55fbb39b54b 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 81be39f8825..6f3c85d62d1 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 57ad3b68cc2..2fd44ecb464 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 37a6f6be644..f68772726b8 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 ec6494967b1..b67471d5600 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 e74b2722477..1b86cdf6909 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 9ebfb12dac3..a0054f741ea 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 ce08643e3e4..8d1eaa0a83c 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 b91922591e5..6528eafe12b 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 5310ea0ebdd98d61cb894416b880b3876dc6049d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 27 May 2020 12:53:54 +0200 Subject: [PATCH 233/710] 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 7e0f0bddc7d..f9e24629192 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 b16a6538fe8..0d987e0da03 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 868821378e5..6e97fa35250 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 f508b8b9bc9..1f83cde1eb4 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 55fbb39b54b..d0612fe1ee0 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 5e827af612ab76a1090467e1a20604fa5c115de8 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 28 May 2020 08:34:12 +0200 Subject: [PATCH 234/710] 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 73cb1395d2a..f427115d007 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 9d5006ffed5a80ffdb0459639fcd8ee2d35a1651 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 29 May 2020 08:53:59 +0200 Subject: [PATCH 235/710] 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 881b56931e2..e40d9814919 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 b67471d5600..11909cccf04 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 166997f5b3cda89e5713dfaba382ae1d1f7f2b32 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 28 May 2020 16:12:41 +0200 Subject: [PATCH 236/710] 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 f9e24629192..2d0d521699b 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 00000000000..2127ea5cbe6 --- /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 00000000000..865d5339881 --- /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 7f9b9869655..1bfd3ece74b 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 00000000000..a17e9a14321 --- /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 8442c179fd5..84321ab4313 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 6aca8c733c7..bdf728b8e5e 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 da1e2d3fb3a..dda562d98e2 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 29876a3a776..f1a619ed6b4 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 00000000000..6ebb576f7cb --- /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 537a63736c6c89ac7fb2946352b4982b2024947e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 29 May 2020 10:56:00 +0200 Subject: [PATCH 237/710] backend: fix packaging handling --- shopfloor/actions/data.py | 20 +++++++++++++++----- shopfloor/services/schema.py | 9 +++------ shopfloor/tests/test_actions_data.py | 15 +++++++++++---- shopfloor/tests/test_actions_data_detail.py | 3 +-- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 0d987e0da03..9d53aa23bf2 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/schema.py b/shopfloor/services/schema.py index 6e97fa35250..ebe1523a29f 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 d0612fe1ee0..395de60b805 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 6f3c85d62d1..d715d4aefaf 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 4ae29261b6f862f62afaa4829e9801d58324a64f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 29 May 2020 11:51:20 +0200 Subject: [PATCH 238/710] 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 b7d41658378..0fc93b7674f 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 4cb3200a6a6b9cf32a39120bd6c255ab18d9d5aa Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 29 May 2020 12:22:46 +0200 Subject: [PATCH 239/710] 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 bebde9dcc5a..dd4dca46957 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 c06cc5422d2b7797aeed017d7ec4cd4415b3cab1 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 2 Jun 2020 12:04:11 +0200 Subject: [PATCH 240/710] 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 865d5339881..7c5809584bc 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 84321ab4313..fe0cc8cdee2 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 dda562d98e2..985e534a8d2 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 c1ddb487ddbb4ea361e3fd4b68b8d29b062638df Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 2 Jun 2020 12:13:12 +0200 Subject: [PATCH 241/710] 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 9d53aa23bf2..c884cfa71cd 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 ebe1523a29f..f894f822502 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 395de60b805..53deab34d31 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 6fd89e027fd5742ade3f718ebb252842886262b1 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 2 Apr 2020 11:39:23 +0200 Subject: [PATCH 242/710] 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 ce6d28630d0..29c96da23d4 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 41547158cff..c3bc49511db 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 9c59f4df349..45143f373eb 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 640b06ae81f..7e1381878bb 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 1f83cde1eb4..97806a081f9 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 a96ff008415..3a6fdae03e0 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 00000000000..d02c16ae410 --- /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 0a6803674c4d6757bc961eb09d0b15275f2351f0 Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 27 May 2020 15:36:30 +0200 Subject: [PATCH 243/710] 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 45143f373eb..8d16752070f 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 97806a081f9..1f83cde1eb4 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 3a6fdae03e0..8fb4e271696 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 d02c16ae410..12445e75389 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 1c05aee9fbd91aa1413df7796efc5f7ff2065608 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 28 May 2020 11:18:38 +0200 Subject: [PATCH 244/710] 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 30bc8e10fd2..9454fdd7f53 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 8d16752070f..d370fdc6e49 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 8fb4e271696..78649825657 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 12445e75389..024feebd01a 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 e2426e6c79dc49c3e4e4e408ce8a1a057160bcb2 Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 29 May 2020 16:17:48 +0200 Subject: [PATCH 245/710] 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 9454fdd7f53..ddb9f7c7124 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 d370fdc6e49..1bfacf1ac16 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 7e1381878bb..5a24485bdf0 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 78649825657..36f92251906 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 024feebd01a..e75e285201f 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 00000000000..aaee4f9a6d7 --- /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 3c7614059e938a441a2327af83276f4f22af2cc4 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 1 Jun 2020 13:25:45 +0200 Subject: [PATCH 246/710] 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 1bfacf1ac16..05a237bd21c 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 5a24485bdf0..9ad84048c08 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 00000000000..6b07dc5a167 --- /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 ef0c1ca0e63a3ba4860c9dfdf48c6529bcbdcd1c Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 1 Jun 2020 14:54:36 +0200 Subject: [PATCH 247/710] 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 05a237bd21c..97fb9bc5ec6 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 c484c1514b4..8bdf4d75cf9 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 9ad84048c08..6e2a597fc7e 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 36f92251906..b0e6310de89 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 00000000000..a44ac8b6d32 --- /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 581248c5d98151569eda9fde48d7007258770d2e Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 2 Jun 2020 09:46:16 +0200 Subject: [PATCH 248/710] 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 97fb9bc5ec6..93890db574a 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 6e2a597fc7e..87bc1469041 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 b0e6310de89..1ab4a35fb73 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 a44ac8b6d32..545a4db4357 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 00000000000..14b214ef82a --- /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 a559dced331081d6463da57778d2734bc9310d39 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 2 Jun 2020 15:33:57 +0200 Subject: [PATCH 249/710] 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 ddb9f7c7124..fce3f881078 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 93890db574a..982383e16b0 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 00000000000..99048b6d5c4 --- /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 41da6bf5e6cc1cc00f170189b054a707a5f2456d Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 2 Jun 2020 18:09:57 +0200 Subject: [PATCH 250/710] 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 fce3f881078..ae2e1fafd78 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 982383e16b0..9073010009e 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 00000000000..ca627ec9e10 --- /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 b4296702c7c0fb1b58589e17b214ca449a209d18 Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 3 Jun 2020 11:45:52 +0200 Subject: [PATCH 251/710] 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 ae2e1fafd78..143bb83e7b2 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 9073010009e..ba1f6293ac7 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 00000000000..e2ef5937194 --- /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 51e745abbecee9f2e344c571b6e83a0625e50edf Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 4 Jun 2020 18:16:44 +0200 Subject: [PATCH 252/710] 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 ba1f6293ac7..420a811bf2f 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 e75e285201f..264510337b7 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 e0e7ccae5afdc0625de1935f387919f7a83c803c Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 5 Jun 2020 10:11:32 +0200 Subject: [PATCH 253/710] backend: new service properties 'data', 'data_detail' and 'msg_store' + update all existing services to use them --- shopfloor/services/checkout.py | 26 +++++---------- 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 | 8 ++--- shopfloor/services/single_pack_transfer.py | 21 ++++-------- shopfloor/tests/test_delivery_base.py | 2 +- 8 files changed, 58 insertions(+), 79 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index f427115d007..225cb65c24c 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -34,14 +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) @@ -69,7 +61,7 @@ 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_struct.pickings(pickings)} + 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): @@ -78,7 +70,7 @@ def _response_for_select_package(self, lines, message=None): next_state="select_package", data={ "selected_move_lines": self._data_for_move_lines(lines.sorted()), - "picking": self.data_struct.picking(picking), + "picking": self.data.picking(picking), }, message=message, ) @@ -95,8 +87,8 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None): "body": _("No valid package to select."), }, ) - picking_data = self.data_struct.picking(picking) - packages_data = self.data_struct.packages( + picking_data = self.data.picking(picking) + packages_data = self.data.packages( packages.sorted(), picking=picking, with_packaging=True ) return self._response( @@ -118,11 +110,11 @@ def _response_for_change_packaging(self, picking, package, packaging_list): return self._response( next_state="change_packaging", data={ - "picking": self.data_struct.picking(picking), - "package": self.data_struct.package( + "picking": self.data.picking(picking), + "package": self.data.package( package, picking=picking, with_packaging=True ), - "packagings": self.data_struct.packagings(packaging_list.sorted()), + "packaging": self.data.packaging_list(packaging_list.sorted()), }, ) @@ -221,10 +213,10 @@ def _select_picking(self, picking, state_for_error): return self._response_for_select_line(picking) def _data_for_move_lines(self, lines, **kw): - return self.data_struct.move_lines(lines, **kw) + return self.data.move_lines(lines, **kw) def _data_for_stock_picking(self, picking, done=False): - data = self.data_struct.picking(picking) + data = self.data.picking(picking) line_picker = self._lines_checkout_done if done else self._lines_to_pack data.update( { diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index e40d9814919..bda81fc7f1f 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 420a811bf2f..18e9681be0e 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 38371070fd2..de8b24dc1f3 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 985e534a8d2..94b79e0ea13 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -277,6 +277,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 a9cfd913e91..415f01b3177 100644 --- a/shopfloor/services/single_pack_putaway.py +++ b/shopfloor/services/single_pack_putaway.py @@ -232,7 +232,7 @@ def _response_for_move_canceled_elsewhere(self): def _response_for_location_not_found(self): 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), ) @@ -301,13 +301,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 adecaf1aed9..76b84a3820b 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 1ab4a35fb73..4fd5097dc9f 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 cc8977396f6e738019365023ca2568b14ad48396 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 5 Jun 2020 11:16:14 +0200 Subject: [PATCH 254/710] 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 b118e86e93e..7e577ba9cc5 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 7736db7a8dc..03fea1c23a7 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 d715d4aefaf..0fef6ab21f1 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 ebba3b1f989b95dd6f6d0c4242dabfa5cb0c4eb2 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 10 Jun 2020 10:20:56 +0200 Subject: [PATCH 255/710] 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 c884cfa71cd..326e19ff917 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 53deab34d31..e2665250367 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 75fe9370b98f9eb5b8929ceab4b7d2c038dedaca Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 23 Jun 2020 15:46:31 +0200 Subject: [PATCH 256/710] 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 0074c72999c..b7adbc2d149 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 8b611b17d7d..68759df75b8 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 895ad52ae9f48bf60fca377e540cdd63b8242851 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 24 Jun 2020 07:46:08 +0200 Subject: [PATCH 257/710] 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 76b84a3820b..085f851a566 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 dd4dca46957..aec7b9ca9ac 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 b1635f4782e6124dead74f837342f7bdc9fadd3d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 24 Jun 2020 07:55:09 +0200 Subject: [PATCH 258/710] 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/tests/__init__.py | 1 - 7 files changed, 1 insertion(+), 63 deletions(-) delete mode 100644 shopfloor/actions/pack_transfer_validate.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index 58c70c29f36..ac8c5da74d2 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 7b80f0aae98..00000000000 --- 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 1276f19a987..0c2f671777b 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -1,16 +1,8 @@ - - Put-Away Reach Truck - 10 - single_pack_putaway - - Single Pallet Transfer 20 + single_pack_transfer - - Put-Away Reach Truck - PART - - - - - - - - - internal - - - Single Pallet Transfer SPT diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index b7adbc2d149..362c96c58a3 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -33,7 +33,6 @@ class ShopfloorMenu(models.Model): 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"), diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index dccb05c8426..85000d1508b 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -14,5 +14,4 @@ from . import checkout from . import cluster_picking from . import delivery -from . import single_pack_putaway from . import single_pack_transfer diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 87bc1469041..5b470c54dfd 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -4,7 +4,6 @@ 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 from . import test_cluster_picking_batch From 5d724e3071e6188a9a651bb4c2ab654156d22e08 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 7 Jul 2020 10:46:23 +0200 Subject: [PATCH 259/710] Add a picking sequence on location To be used in scenarios to sort the move lines. Asking by location name may not be accurate for several reasons. We cannot rely solely on this. Using a sequence guarantees we have strictly the desired order. --- shopfloor/models/stock_location.py | 5 +++++ shopfloor/services/cluster_picking.py | 1 + shopfloor/views/stock_location.xml | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index a1f1614a87d..9bd822d1528 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -4,6 +4,11 @@ class StockLocation(models.Model): _inherit = "stock.location" + shopfloor_picking_sequence = fields.Integer( + string="Shopfloor Picking Sequence", + default=0, + help="The picking done in Shopfloor scenarios will respect this order.", + ) source_move_line_ids = fields.One2many( comodel_name="stock.move.line", inverse_name="location_id", readonly=True ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index bda81fc7f1f..75c849ebc7d 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -321,6 +321,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.name, line.move_id.sequence, line.move_id.id, diff --git a/shopfloor/views/stock_location.xml b/shopfloor/views/stock_location.xml index b3afe523eeb..324d8964790 100644 --- a/shopfloor/views/stock_location.xml +++ b/shopfloor/views/stock_location.xml @@ -10,6 +10,11 @@ From 466fa89f48c572913499bab640b86f69e613108a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 7 Jul 2020 09:26:45 +0200 Subject: [PATCH 260/710] 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 09c80428653..c64cf5060d9 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 f974b0257a1..cbe326fc8da 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 75c849ebc7d..88fe45175d1 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 11909cccf04..3c097a4ee81 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 25313e15862..ad90a59d5b9 100644 --- a/shopfloor/views/stock_picking_type.xml +++ b/shopfloor/views/stock_picking_type.xml @@ -5,9 +5,15 @@ stock.picking.type - - - + + + + + + From 9d5bf19e1978bb50d7972268865d6a0692dff080 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 10 Jun 2020 15:39:00 +0200 Subject: [PATCH 261/710] 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 0c2f671777b..2880a74a80e 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'))]" /> + + Location Content Transfer + 60 + location_content_transfer + + diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index c64cf5060d9..2dbedd83ad6 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 362c96c58a3..269d87aa01d 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 d266f38ccea..cdcd93cd080 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 85000d1508b..09470e83eeb 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 00000000000..d8e98a9366c --- /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 5b470c54dfd..fb9f55a585d 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 00000000000..f66e8be98f0 --- /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 e404849e432840e8bc152dfacc714106fadf9676 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 17 Jun 2020 14:49:08 +0200 Subject: [PATCH 262/710] 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 ac8c5da74d2..42f45d7eeee 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 326e19ff917..c2efca8ba62 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 00000000000..6777636dca2 --- /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 143bb83e7b2..db160ee3f42 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 d8e98a9366c..9effb63b7ea 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 f894f822502..c019d057e5a 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 085f851a566..2306432b188 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 fb9f55a585d..83efe92bcf0 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 1f83cde1eb4..9fbd131bec9 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 f66e8be98f0..c5ebe0569c0 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 00000000000..378048a7bcd --- /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 65aeb1ef2cf5355265718f746bb1124c5a0cb94a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 08:09:38 +0200 Subject: [PATCH 263/710] 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 225cb65c24c..feb27307de8 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 f60abd087ada0535c77103f1527e7bcc7fb4c3c7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 10:53:14 +0200 Subject: [PATCH 264/710] 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 6777636dca2..a79bc692422 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 9effb63b7ea..51bc7ea5bc1 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 83efe92bcf0..d368870c0a8 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 c5ebe0569c0..078f40a6ca2 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 00000000000..be48916af06 --- /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 4984e6413bb97293481af55e7e035dd3a200fb15 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 11:51:05 +0200 Subject: [PATCH 265/710] 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 51bc7ea5bc1..53843f03ebe 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 be48916af06..022fb48e456 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 b86ca69d37f2ae4ea3f09c515b9f2ad1e62d339c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 14:59:46 +0200 Subject: [PATCH 266/710] 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 53843f03ebe..643e868da52 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 d368870c0a8..4584cde8dcd 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 078f40a6ca2..33010fe9660 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 00000000000..ef437e79697 --- /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 7e0d4c787b746b53b591b7f1ce7462c84c0c2699 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 22 Jun 2020 12:13:57 +0200 Subject: [PATCH 267/710] 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 643e868da52..21b858477aa 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 9fbd131bec9..b55dc7f2698 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 ef437e79697..d5b89bcd693 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 88f48c07775d18a183ee719f843451649523dc43 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 23 Jun 2020 13:29:15 +0200 Subject: [PATCH 268/710] 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 21b858477aa..76325bf6098 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 022f39815b1d822bb3c7fb8728d316dbaba1bfd0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 24 Jun 2020 10:01:34 +0200 Subject: [PATCH 269/710] 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 76325bf6098..8d0ffca7225 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 3fa5fafd64b5ad2f166fea61f14221541ee79c2f Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 30 Jun 2020 18:16:13 +0200 Subject: [PATCH 270/710] 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 db160ee3f42..c497b469246 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 2880a74a80e..4929c516c04 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -39,6 +39,7 @@ Location Content Transfer 60 + location_content_transfer ", 0)] + ) + # create moves for each quant + picking_type = self.work.menu.picking_type_ids + move_vals_list = [] + for quant in quants: + move_vals_list.append( + { + "name": quant.product_id.name, + "company_id": picking_type.company_id.id, + "product_id": quant.product_id.id, + "product_uom": quant.product_uom_id.id, + "product_uom_qty": quant.quantity, + "location_id": location.id, + "location_dest_id": picking_type.default_location_dest_id.id, + "origin": self.work.menu.name, + "picking_type_id": picking_type.id, + } + ) + return self.env["stock.move"].create(move_vals_list) + def scan_location(self, barcode): """Scan start location @@ -233,9 +254,6 @@ 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") @@ -253,6 +271,31 @@ def scan_location(self, barcode): "body": _("This location content can't be moved using this menu."), } ) + # If the following criteria are met: + # - no move lines have been found + # - the menu is configured to allow the creation of moves + # - the menu is bind to one picking type + # - scanned location is a child of the picking type source location + # then prepare new stock moves to move goods from the scanned location. + menu = self.work.menu + if ( + not move_lines + and menu.allow_move_create + and len(menu.picking_type_ids) == 1 + and location.is_sublocation_of( + menu.picking_type_ids.default_location_src_id + ) + ): + new_moves = self._create_moves_from_location(location) + new_moves._action_confirm(merge=False) + new_moves._action_assign() + pickings = new_moves.mapped("picking_id") + move_lines = new_moves.move_line_ids + + if not pickings: + return self._response_for_start( + message=self.msg_store.location_empty(location) + ) for line in move_lines: line.qty_done = line.product_uom_qty diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py index 378048a7bcd..35b01f465de 100644 --- a/shopfloor/tests/test_location_content_transfer_start.py +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -172,3 +172,54 @@ def test_scan_location_wrong_picking_type(self): "body": "This location content can't be moved using this menu.", }, ) + + 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 + # product_a alone + self.env["stock.quant"]._update_available_quantity( + self.product_a, self.content_loc, 10, + ) + # product_b in a package + package = self.env["stock.quant.package"].create({}) + self.env["stock.quant"]._update_available_quantity( + self.product_b, self.content_loc, 10, package_id=package + ) + # product_c & product_d in a package + package2 = self.env["stock.quant.package"].create({}) + self.env["stock.quant"]._update_available_quantity( + self.product_c, self.content_loc, 5, package_id=package2 + ) + self.env["stock.quant"]._update_available_quantity( + self.product_d, self.content_loc, 5, package_id=package2 + ) + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + picking = self.env["stock.picking"].search( + [("picking_type_id", "=", picking_type.id)] + ) + self.assertEqual(len(picking), 1) + self.assert_response_scan_destination_all(response, picking) + move_line_id = response["data"]["scan_destination_all"]["move_lines"][0]["id"] + package_levels = response["data"]["scan_destination_all"]["package_levels"] + self.assertIn(move_line_id, picking.move_line_ids.ids) + self.assertEqual(package_levels[0]["id"], picking.package_level_ids[0].id) + self.assertEqual(package_levels[0]["package"]["id"], package.id) + self.assertEqual(package_levels[1]["id"], picking.package_level_ids[1].id) + self.assertEqual(package_levels[1]["package"]["id"], package2.id) + # product_a in a move line without package + self.assertEqual( + picking.move_line_ids_without_package.mapped("product_id"), self.product_a + ) + # all other products are in package levels + self.assertEqual( + picking.package_level_ids.mapped("package_id.quant_ids.product_id"), + self.product_b | self.product_c | self.product_d, + ) + # all products are in move lines + self.assertEqual( + picking.move_line_ids.mapped("product_id"), + self.product_a | self.product_b | self.product_c | self.product_d, + ) + self.assertEqual(picking.state, "assigned") From b81ca863010fea92475ac68d20b1d3c67839c1c5 Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 1 Jul 2020 11:56:47 +0200 Subject: [PATCH 271/710] location transfer: add a test for /scan_package if location doesn't exist --- .../tests/test_location_content_transfer_single.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index d5b89bcd693..1694089fa35 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -56,6 +56,19 @@ def _test_scan_package_ok(self, barcode): ) self.assert_response_scan_destination(response, package_level) + def test_scan_package_location_not_found(self): + response = self.service.dispatch( + "scan_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": 42, + "barcode": "TEST", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + 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) From 47fb17d17e2ae0998e4f1cfa2eb3718658f1c7f7 Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 1 Jul 2020 11:58:12 +0200 Subject: [PATCH 272/710] location transfer: improve /go_to_single to redirect on 'start' screen if there is no move lines to process --- .../services/location_content_transfer.py | 4 ++++ ...on_content_transfer_set_destination_all.py | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 0844d138bf9..87479e5e6f0 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -382,6 +382,10 @@ def go_to_single(self, 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) + if not move_lines: + return self._response_for_start( + message=self.msg_store.no_lines_to_process() + ) return self._response_for_start_single(move_lines.mapped("picking_id")) def scan_package(self, location_id, package_level_id, barcode): 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 022fb48e456..c40f5662501 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -152,3 +152,25 @@ def test_go_to_single(self): "go_to_single", params={"location_id": self.content_loc.id} ) self.assert_response_start_single(response, self.pickings) + + +class LocationContentTransferSetDestinationAllSpecialCase( + LocationContentTransferCommonCase +): + """Tests for endpoint used from scan_destination_all (special cases without setup) + + * /set_destination_all + * /go_to_single + + """ + + def test_go_to_single_no_lines_to_process(self): + """User used to 'split by lines' button to process line per line, + but no lines to process. + """ + response = self.service.dispatch( + "go_to_single", params={"location_id": self.content_loc.id} + ) + self.assert_response_start( + response, message=self.service.msg_store.no_lines_to_process() + ) From cfcf642c3a48751e656b36e94e842e7b3bd025d7 Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 1 Jul 2020 18:20:01 +0200 Subject: [PATCH 273/710] location transfer: implement /set_destination_package and /set_destination_line endpoints --- shopfloor/models/shopfloor_menu.py | 5 +- .../services/location_content_transfer.py | 95 +++- shopfloor/tests/__init__.py | 1 + ...ransfer_set_destination_package_or_line.py | 506 ++++++++++++++++++ 4 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 269d87aa01d..51f97a56ff1 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -6,7 +6,10 @@ class ShopfloorMenu(models.Model): _description = "Menu displayed in the scanner application" _order = "sequence" - _scenario_allowing_create_moves = ("single_pack_transfer",) + _scenario_allowing_create_moves = ( + "single_pack_transfer", + "location_content_transfer", + ) name = fields.Char(translate=True) sequence = fields.Integer() diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 87479e5e6f0..bcafcd9a085 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -512,8 +512,52 @@ def set_destination_package( Transitions: * scan_destination: invalid destination or could not * start_single: continue with the next package level / line + * start: if there is no more package level / line to process """ - 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")) + search = self.actions_for("search") + scanned_location = search.location_from_scan(barcode) + if not scanned_location: + return self._response_for_scan_destination( + location, package_level, message=self.msg_store.no_location_found() + ) + if not scanned_location.is_sublocation_of( + package_level.picking_id.picking_type_id.default_location_dest_id + ): + return self._response_for_scan_destination( + location, + package_level, + message=self.msg_store.dest_location_not_allowed(), + ) + if not scanned_location.is_sublocation_of(package_level.location_dest_id): + if not confirmation: + return self._response_for_confirm_scan_destination( + location, package_level + ) + package_move_lines = package_level.move_line_ids + 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. + other_move_lines = package_move.move_line_ids - package_move_lines + if other_move_lines: + qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) + backorder_move_id = package_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() + if package_move_lines == package_moves.mapped("move_line_ids"): + 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) + return self._response_for_start_single(move_lines.mapped("picking_id")) def set_destination_line( self, location_id, move_line_id, quantity, barcode, confirmation=False @@ -536,8 +580,55 @@ def set_destination_line( Transitions: * scan_destination: invalid destination or could not * start_single: continue with the next package level / line + * start: if there is no more package level / line to process """ - 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")) + search = self.actions_for("search") + scanned_location = search.location_from_scan(barcode) + if not scanned_location: + return self._response_for_scan_destination( + location, move_line, message=self.msg_store.no_location_found() + ) + if not scanned_location.is_sublocation_of( + move_line.picking_id.picking_type_id.default_location_dest_id + ): + return self._response_for_scan_destination( + location, move_line, message=self.msg_store.dest_location_not_allowed() + ) + if not scanned_location.is_sublocation_of(move_line.location_dest_id): + if not confirmation: + return self._response_for_confirm_scan_destination(location, 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 + # (by splitting the current one) + move_line.product_uom_qty = move_line.qty_done = quantity + current_move = move_line.move_id + new_move_id = current_move._split(quantity) + 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)._action_assign() + for remaining_move_line in current_move.move_line_ids: + remaining_move_line.qty_done = remaining_move_line.product_uom_qty + other_move_lines = move_line.move_id.move_line_ids - move_line + if other_move_lines: + qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) + backorder_move_id = move_line.move_id._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() + 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) + return self._response_for_start_single(move_lines.mapped("picking_id")) def postpone_package(self, location_id, package_level_id): """Mark a package level as postponed and return the next level/line diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 4584cde8dcd..61dfbc481b4 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -37,3 +37,4 @@ from . import test_location_content_transfer_start 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 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 new file mode 100644 index 00000000000..074c8d20ccc --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -0,0 +1,506 @@ +from .test_location_content_transfer_base import LocationContentTransferCommonCase + + +class LocationContentTransferSetDestinationXCase(LocationContentTransferCommonCase): + """Tests for endpoint used from scan_destination + + * /set_destination_package + * /set_destination_line + + """ + + # 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) + cls.dest_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": cls.shelf1.id, + } + ) + ) + + def test_set_destination_package_wrong_parameters(self): + """Wrong 'location' and 'package_level_id' parameters, redirect the + user to the 'start' screen. + """ + package_level = self.picking1.package_level_ids[0] + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": package_level.id, + "barcode": "TEST", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": 1234567890, # Doesn't exist + "barcode": "TEST", + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_set_destination_package_dest_location_nok(self): + """Scanned destination location not valid, redirect to 'scan_destination'.""" + package_level = self.picking1.package_level_ids[0] + # Unknown destination location + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": "UNKNOWN_LOCATION", + }, + ) + self.assert_response_scan_destination( + response, package_level, message=self.service.msg_store.no_location_found(), + ) + # Destination location not allowed + customer_location = self.env.ref("stock.stock_location_customers") + customer_location.sudo().barcode = "CUSTOMER" + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": customer_location.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] + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.env.ref("stock.stock_location_14").barcode, + }, + ) + self.assert_response_confirm_scan_destination( + response, package_level, + ) + + 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, + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + 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. + """ + move_line = self.picking2.move_line_ids[0] + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": 1234567890, # Doesn't exist + "move_line_id": move_line.id, + "quantity": move_line.product_uom_qty, + "barcode": "TEST", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": 1234567890, # Doesn't exist + "quantity": move_line.product_uom_qty, + "barcode": "TEST", + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_set_destination_line_dest_location_nok(self): + """Scanned destination location not valid, redirect to 'scan_destination'.""" + move_line = self.picking2.move_line_ids[0] + # Unknown destination location + 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": "UNKNOWN_LOCATION", + }, + ) + self.assert_response_scan_destination( + response, move_line, message=self.service.msg_store.no_location_found(), + ) + # Destination location not allowed + customer_location = self.env.ref("stock.stock_location_customers") + customer_location.sudo().barcode = "CUSTOMER" + 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": customer_location.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] + 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.env.ref("stock.stock_location_14").barcode, + }, + ) + self.assert_response_confirm_scan_destination(response, move_line) + + 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] + 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, + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + 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( + lambda m: m.product_id == self.product_c + ) + self.assertEqual(move_line_c.product_uom_qty, 10) + self.assertEqual(move_line_c.qty_done, 10) + # Scan partial qty (6/10) + response = 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") + # 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) + # 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"), + ) + 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, + }, + ) + # 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( + lambda m: m.product_id == self.product_c + ) + moves_product_c_done = all(move.state == "done" for move in moves_product_c) + self.assertTrue(moves_product_c_done) + 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") + # Let scan product_d quantity and check picking state + 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") + + +class LocationContentTransferSetDestinationXSpecialCase( + LocationContentTransferCommonCase +): + """Tests for endpoint used from scan_destination (special cases) + + * /set_destination_package + * /set_destination_line + + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + 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, + } + ) + + cls.picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.move_product_a = cls.picking.move_lines.filtered( + lambda m: m.product_id == cls.product_a + ) + cls.move_product_b = cls.picking.move_lines.filtered( + lambda m: m.product_id == cls.product_b + ) + # Change the initial demand of product_a to get two move lines for + # reserved qties: + # - 10 from the package + # - 5 from the qty without package + cls._fill_stock_for_moves( + cls.move_product_a, in_package=True, location=cls.content_loc + ) + cls.move_product_a.product_uom_qty = 15 + cls._update_qty_in_location( + 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) + cls._update_qty_in_location(cls.picking.location_id, cls.product_b, 6) + cls._update_qty_in_location(cls.content_loc, cls.product_b, 4) + # Reserve quantities + cls.picking.action_assign() + cls._simulate_pickings_selected(cls.picking) + cls.dest_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": cls.shelf1.id, + } + ) + ) + + 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) + self.assertEqual(len(self.move_product_a.move_line_ids), 2) + package_level = self.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 picking data + 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( + lambda m: m.product_id == self.product_a + ) + self.assertEqual(len(self.picking.move_lines), 3) + self.assertEqual(len(moves_product_a), 2) + 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_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") + # 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"), + ) + # self.assert_response_start( + # response, + # # FIXME: message needs to be refactored to avoid "False" as location name + # message=self.service.msg_store.location_content_transfer_complete( + # self.env["stock.location"] + # ), + # ) + + 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) + 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 + ) + 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 picking data + self.assertEqual(self.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( + lambda m: m.product_id == self.product_b + ) + self.assertEqual(len(self.picking.move_lines), 3) + self.assertEqual(len(moves_product_b), 2) + 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_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(move_line.state, "done") + remaining_move = self.picking.move_lines.filtered( + lambda m: move_line.move_id != m and m.product_id == self.product_b + ) + self.assertEqual(remaining_move.state, "assigned") + self.assertEqual(remaining_move.product_uom_qty, 4) + self.assertEqual(remaining_move.move_line_ids.product_uom_qty, 4) + self.assertEqual(remaining_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(response, move_lines.mapped("picking_id")) + # 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( + lambda ml: ml.state == "assigned" + ) + 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") + 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") From 985a6f7afebf09a73a92192cef5e07a356811638 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 6 Jul 2020 12:01:16 +0200 Subject: [PATCH 274/710] location transfer: implement /postpone_package and /postpone_line endpoints --- .../location_content_transfer_sorter.py | 3 +- shopfloor/models/stock_package_level.py | 9 +- .../services/location_content_transfer.py | 24 +++-- .../test_location_content_transfer_single.py | 100 ++++++++++++++++++ 4 files changed, 126 insertions(+), 10 deletions(-) diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py index a79bc692422..80ca0488fe1 100644 --- a/shopfloor/actions/location_content_transfer_sorter.py +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -32,7 +32,8 @@ 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) + # postponed content after other contents + int(content.shopfloor_postponed), # 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/models/stock_package_level.py b/shopfloor/models/stock_package_level.py index 1db120cc2f4..1aa691c6e95 100644 --- a/shopfloor/models/stock_package_level.py +++ b/shopfloor/models/stock_package_level.py @@ -1,9 +1,16 @@ -from odoo import models +from odoo import fields, 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.", + ) + def replace_package(self, new_package): """Replace a package on an assigned package level and related records diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index bcafcd9a085..b42c3db5548 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -633,24 +633,32 @@ def set_destination_line( 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() + location = self.env["stock.location"].browse(location_id) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + if package_level.exists(): + package_level.shopfloor_postponed = True + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) 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() + 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 move_line.exists(): + move_line.shopfloor_postponed = True + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) def stock_out_package(self, location_id, package_level_id): """Declare a stock out on a package level diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index 1694089fa35..5d0f7bb5b84 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -6,6 +6,8 @@ class LocationContentTransferSingleCase(LocationContentTransferCommonCase): * /scan_package * /scan_line + * /postpone_package + * /postpone_line """ @@ -286,3 +288,101 @@ def test_scan_line_move_line_not_exists(self): "NOT_FOUND", self.service.msg_store.record_not_found(), ) + + def test_postpone_package_wrong_parameters(self): + """Wrong 'location_id' and 'package_level_id' parameters, redirect the + user to the 'start' screen. + """ + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "postpone_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": package_level.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "postpone_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_postpone_package_ok(self): + package_level = self.picking1.move_line_ids.package_level_id + self.assertFalse(package_level.shopfloor_postponed) + response = self.service.dispatch( + "postpone_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + }, + ) + 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"), + ) + + def test_postpone_sorter(self): + move_line = self.picking2.move_line_ids[0] + move_lines = self.service._find_transfer_move_lines(self.content_loc) + pickings = move_lines.mapped("picking_id") + sorter = self.service.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + content_sorted1 = list(sorter) + self.service.dispatch( + "postpone_line", + params={"location_id": self.content_loc.id, "move_line_id": move_line.id}, + ) + sorter.sort() + content_sorted2 = list(sorter) + self.assertTrue(content_sorted1 != content_sorted2) + + def test_postpone_line_wrong_parameters(self): + """Wrong 'location_id' and 'move_line_id' parameters, redirect the + user to the 'start' screen. + """ + move_line = self.picking2.move_line_ids[0] + response = self.service.dispatch( + "postpone_line", + params={ + "location_id": 1234567890, # Doesn't exist + "move_line_id": move_line.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "postpone_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_postpone_line_ok(self): + move_line = self.picking2.move_line_ids[0] + self.assertFalse(move_line.shopfloor_postponed) + response = self.service.dispatch( + "postpone_line", + params={"location_id": self.content_loc.id, "move_line_id": move_line.id}, + ) + 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"), + ) From 4c201dcef0078351fa4cec24a6480dac927eecb9 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 7 Jul 2020 11:42:07 +0200 Subject: [PATCH 275/710] location transfer: implement /stock_out_package and /stock_out_line endpoints --- .../services/location_content_transfer.py | 103 ++++++-- .../test_location_content_transfer_single.py | 229 ++++++++++++++++++ 2 files changed, 313 insertions(+), 19 deletions(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index b42c3db5548..bc48d5a891f 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -325,6 +325,20 @@ 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 @@ -546,16 +560,9 @@ 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. - other_move_lines = package_move.move_line_ids - package_move_lines - if other_move_lines: - qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) - backorder_move_id = package_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() - if package_move_lines == package_moves.mapped("move_line_ids"): - package_level.location_dest_id = scanned_location - package_moves.with_context(_sf_no_backorder=True)._action_done() + self._split_other_move_lines(package_move, 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) return self._response_for_start_single(move_lines.mapped("picking_id")) @@ -618,13 +625,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 - other_move_lines = move_line.move_id.move_line_ids - move_line - if other_move_lines: - qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) - backorder_move_id = move_line.move_id._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() + self._split_other_move_lines(move_line.move_id, 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) @@ -675,7 +676,48 @@ def stock_out_package(self, location_id, package_level_id): * start: no more content to move * start_single: continue with the next package level / line """ - 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")) + inventory = self.actions_for("inventory") + package_move_lines = package_level.move_line_ids + 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. + self._split_other_move_lines(package_move, package_move_lines) + lot = package_move.move_line_ids.lot_id + package_move._do_unreserve() + package_move._recompute_state() + # Create an inventory at 0 in the move's source location + inventory.create_stock_issue( + package_move, location, package_level.package_id, lot + ) + # Create a draft inventory to control stock + inventory.create_control_stock( + location, package_move.product_id, package_level.package_id, lot + ) + package_move._action_cancel() + # remove the package level (this is what does the `picking.do_unreserve()` + # method, but here we want to unreserve+unlink this package alone) + assert package_level.state == "draft", "Package level has to be in draft" + if package_level.state != "draft": + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message={ + "message_type": "error", + "body": _("Package level has to be in draft"), + }, + ) + package_level.unlink() + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) def stock_out_line(self, location_id, move_line_id): """Declare a stock out on a move line @@ -692,7 +734,30 @@ def stock_out_line(self, location_id, move_line_id): * start: no more content to move * start_single: continue with the next package level / line """ - 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")) + inventory = self.actions_for("inventory") + self._split_other_move_lines(move_line.move_id, move_line) + move_line_src_location = move_line.location_id + move = move_line.move_id + package = move_line.package_id + lot = move_line.lot_id + move._do_unreserve() + move._recompute_state() + # Create an inventory at 0 in the move's source location + inventory.create_stock_issue(move, move_line_src_location, package, lot) + # Create a draft inventory to control stock + inventory.create_control_stock( + move_line_src_location, move.product_id, package, lot + ) + move._action_cancel() + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) class ShopfloorLocationContentTransferValidator(Component): diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index 5d0f7bb5b84..599b6aabd7f 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -386,3 +386,232 @@ def test_postpone_line_ok(self): self.assert_response_start_single( response, move_lines.mapped("picking_id"), ) + + def test_stock_out_package_wrong_parameters(self): + """Wrong 'location_id' and 'package_level_id' parameters, redirect the + user to the 'start' screen. + """ + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": package_level.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_stock_out_package_ok(self): + """Declare a stock out on a package_level.""" + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_stock_out_line_wrong_parameters(self): + """Wrong 'location_id' and 'move_line_id' parameters, redirect the + user to the 'start' screen. + """ + move_line = self.picking2.move_line_ids[0] + response = self.service.dispatch( + "stock_out_line", + params={ + "location_id": 1234567890, # Doesn't exist + "move_line_id": move_line.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "stock_out_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + +class LocationContentTransferSingleSpecialCase(LocationContentTransferCommonCase): + """Tests for endpoint used from state start_single (special cases) + + * /stock_out_package + * /stock_out_line + + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a | cls.product_b + 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.picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.move_product_a = cls.picking.move_lines.filtered( + lambda m: m.product_id == cls.product_a + ) + cls.move_product_b = cls.picking.move_lines.filtered( + lambda m: m.product_id == cls.product_b + ) + # Change the initial demand of product_a to get two move lines for + # reserved qties: + # - 10 from the package + # - 5 from the qty without package + cls._fill_stock_for_moves( + cls.move_product_a, in_package=True, location=cls.content_loc + ) + cls.move_product_a.product_uom_qty = 15 + cls._update_qty_in_location( + 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) + cls._update_qty_in_location(cls.picking.location_id, cls.product_b, 6) + cls._update_qty_in_location(cls.content_loc, cls.product_b, 4) + # Reserve quantities + cls.picking.action_assign() + cls._simulate_pickings_selected(cls.picking) + + def test_stock_out_package_split_move(self): + """Declare a stock out on a package_level related to moves containing + other unrelated move lines. + """ + package_level = self.picking.move_line_ids.package_level_id + self.assertEqual(self.product_a.qty_available, 15) + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + }, + ) + # Check the picking data + self.assertFalse(package_level.exists()) + moves_product_a = self.picking.move_lines.filtered( + lambda m: m.product_id == self.product_a + ) + self.assertEqual(len(moves_product_a), 2) + move_product_a = moves_product_a.filtered( + lambda m: m.state not in ("cancel", "done") + ) + self.assertEqual(len(move_product_a), 1) + self.assertEqual(move_product_a.state, "assigned") + self.assertEqual(len(move_product_a.move_line_ids), 1) + # Check the inventories + stock_issue_inventory = self.env["stock.inventory"].search( + [ + ("line_ids.location_id", "=", self.content_loc.id), + ("line_ids.product_id", "=", self.product_a.id), + ("state", "=", "done"), + ] + ) + self.assertTrue(stock_issue_inventory) + stock_issue_inventory_line = stock_issue_inventory.line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # 5/15 remaining + self.assertEqual(stock_issue_inventory_line.product_qty, 0) + self.assertEqual(self.product_a.qty_available, 5) + control_inventory = self.env["stock.inventory"].search( + [ + ("location_ids", "in", self.content_loc.id), + ("product_ids", "in", self.product_a.id), + ("state", "in", ("draft", "confirm")), + ] + ) + self.assertTrue(control_inventory) + # 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"), + ) + + def test_stock_out_line_split_move(self): + """Declare a stock out on a move line related to moves containing + other move lines. + """ + self.assertEqual(len(self.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 == 4 # 4/10 to stock out + ) + self.assertEqual(self.product_b.qty_available, 10) + response = self.service.dispatch( + "stock_out_line", + params={"location_id": self.content_loc.id, "move_line_id": move_line.id}, + ) + # Check the picking data + self.assertFalse(move_line.exists()) + moves_product_b = self.picking.move_lines.filtered( + lambda m: m.product_id == self.product_b + ) + self.assertEqual(len(moves_product_b), 2) + move_product_b = moves_product_b.filtered( + lambda m: m.state not in ("cancel", "done") + ) + self.assertEqual(len(move_product_b), 1) + self.assertEqual(move_product_b.state, "assigned") + self.assertEqual(len(move_product_b.move_line_ids), 1) + # Check the inventories + stock_issue_inventory = self.env["stock.inventory"].search( + [ + ("line_ids.location_id", "=", self.content_loc.id), + ("line_ids.product_id", "=", self.product_b.id), + ("state", "=", "done"), + ] + ) + self.assertTrue(stock_issue_inventory) + stock_issue_inventory_line = stock_issue_inventory.line_ids.filtered( + lambda l: l.product_id == self.product_b + ) + # 0/4 remaining in the move line's source location + self.assertEqual(stock_issue_inventory_line.product_qty, 0) + # 6/10 remaining elsewhere in the stock + self.assertEqual(self.product_b.qty_available, 6) + control_inventory = self.env["stock.inventory"].search( + [ + ("location_ids", "in", self.content_loc.id), + ("product_ids", "in", self.product_b.id), + ("state", "in", ("draft", "confirm")), + ] + ) + self.assertTrue(control_inventory) + # 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"), + ) From 14876c79291962f509aa96e9556d370749dee184 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 7 Jul 2020 15:29:25 +0200 Subject: [PATCH 276/710] location transfer: handle 'shopfloor_picking_sequence' sort key --- shopfloor/actions/location_content_transfer_sorter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py index 80ca0488fe1..154dedb2813 100644 --- a/shopfloor/actions/location_content_transfer_sorter.py +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -34,6 +34,8 @@ def _sort_key(content): return ( # postponed content after other contents int(content.shopfloor_postponed), + # sort by shopfloor picking sequence + content.location_dest_id.shopfloor_picking_sequence, # sort by similar destination content.location_dest_id.complete_name, # lines before packages (if we have raw products and packages, raw From bcb27fe0514b0ed4d42f01d2ce68aa7e4456496a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 13 Jul 2020 12:02:44 +0200 Subject: [PATCH 277/710] location transfer: improve pkg level parser --- shopfloor/actions/data.py | 28 +++++++------ .../services/location_content_transfer.py | 1 - shopfloor/services/schema.py | 40 +++++++------------ shopfloor/tests/test_actions_data.py | 28 +++++++++++++ .../test_location_content_transfer_start.py | 4 +- 5 files changed, 60 insertions(+), 41 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index c2efca8ba62..09ead326eb1 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -149,17 +149,7 @@ def _move_line_parser(self): ] 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 + return self._jsonify(record, self._package_level_parser) def package_levels(self, records, **kw): return [self.package_level(rec, **kw) for rec in records] @@ -169,8 +159,22 @@ def _package_level_parser(self): return [ "id", "is_done", - ("package_id:package", self._package_parser), + ("package_id:package_src", self._package_parser), ("location_dest_id:location_dest", self._location_parser), + ( + "location_id:location_src", + lambda rec, fname: self.location(rec.picking_id.location_id), + ), + # tnx to stock_quant_package_product_packaging + ( + "package_id:product", + lambda rec, fname: self.product(rec.package_id.single_product_id), + ), + # TODO: allow to pass mapped path to base_jsonify + ( + "package_id:quantity", + lambda rec, fname: rec.package_id.single_product_qty, + ), ] def product(self, record, **kw): diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index bc48d5a891f..51572b345ef 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -878,7 +878,6 @@ def _schema_single(self): 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), } diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index c019d057e5a..c7d78c39e38 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -76,35 +76,21 @@ def move_line(self, with_packaging=False): "id": {"type": "integer", "required": True}, "qty_done": {"type": "float", "required": True}, "quantity": {"type": "float", "required": True}, - "product": {"type": "dict", "required": True, "schema": self.product()}, + "product": self._schema_dict_of(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, - "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(), - }, + "package_src": self._schema_dict_of( + self.package(with_packaging=with_packaging) + ), + "package_dest": self._schema_dict_of( + self.package(with_packaging=with_packaging), required=False + ), + "location_src": self._schema_dict_of(self.location()), + "location_dest": self._schema_dict_of(self.location()), } def product(self): @@ -171,7 +157,9 @@ def package_level(self): return { "id": {"required": True, "type": "integer"}, "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()}, + "package_src": self._schema_dict_of(self.package()), + "location_src": self._schema_dict_of(self.location()), + "location_dest": self._schema_dict_of(self.location()), + "product": self._schema_dict_of(self.product()), + "quantity": {"type": "float", "required": True}, } diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index e2665250367..4be811ac455 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -88,6 +88,15 @@ def _expected_packaging(self, record, **kw): data.update(kw) return data + def _expected_package(self, record, **kw): + data = { + "id": record.id, + "name": record.name, + "weight": record.pack_weight, + } + data.update(kw) + return data + class ActionsDataCase(ActionsDataCaseBase): def test_data_packaging(self): @@ -133,6 +142,25 @@ def test_data_package(self): } self.assertDictEqual(data, expected) + def test_data_package_level(self): + package_level = self.picking.package_level_ids[0] + data = self.data.package_level(package_level) + self.assert_schema(self.schema.package_level(), data) + expected = { + "id": package_level.id, + "is_done": False, + "package_src": self._expected_package(package_level.package_id), + "location_dest": self._expected_location(package_level.location_dest_id), + "location_src": self._expected_location( + package_level.picking_id.location_id + ), + "product": self._expected_product( + package_level.package_id.single_product_id + ), + "quantity": package_level.package_id.single_product_qty, + } + self.assertDictEqual(data, expected) + def test_data_picking(self): self.picking.write({"origin": "created by test", "note": "read me"}) data = self.data.picking(self.picking) diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py index 35b01f465de..66786e1023d 100644 --- a/shopfloor/tests/test_location_content_transfer_start.py +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -205,9 +205,9 @@ def test_scan_location_create_moves(self): package_levels = response["data"]["scan_destination_all"]["package_levels"] self.assertIn(move_line_id, picking.move_line_ids.ids) self.assertEqual(package_levels[0]["id"], picking.package_level_ids[0].id) - self.assertEqual(package_levels[0]["package"]["id"], package.id) + self.assertEqual(package_levels[0]["package_src"]["id"], package.id) self.assertEqual(package_levels[1]["id"], picking.package_level_ids[1].id) - self.assertEqual(package_levels[1]["package"]["id"], package2.id) + self.assertEqual(package_levels[1]["package_src"]["id"], package2.id) # product_a in a move line without package self.assertEqual( picking.move_line_ids_without_package.mapped("product_id"), self.product_a From 5cc07ea65ed1a12bed85bb9666189dae53465b29 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:24:23 +0200 Subject: [PATCH 278/710] location transfer bknd: add msg for completed item --- shopfloor/actions/message.py | 6 +++ .../services/location_content_transfer.py | 14 ++++++- ...ransfer_set_destination_package_or_line.py | 39 +++++++++++++------ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index c497b469246..300a1af6793 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -299,6 +299,12 @@ def transfer_complete(self, picking): "body": _("Transfer {} complete").format(picking.name), } + def location_content_transfer_item_complete(self, location_dest): + return { + "message_type": "success", + "body": _("Content transfer to {} completed").format(location_dest.name), + } + def location_content_transfer_complete(self, location): return { "message_type": "success", diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 51572b345ef..cb0d9044f20 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -564,7 +564,12 @@ def set_destination_package( 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) - return self._response_for_start_single(move_lines.mapped("picking_id")) + message = self.msg_store.location_content_transfer_item_complete( + scanned_location + ) + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=message + ) def set_destination_line( self, location_id, move_line_id, quantity, barcode, confirmation=False @@ -629,7 +634,12 @@ def set_destination_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) - return self._response_for_start_single(move_lines.mapped("picking_id")) + message = self.msg_store.location_content_transfer_item_complete( + scanned_location + ) + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=message + ) def postpone_package(self, location_id, package_level_id): """Mark a package level as postponed and return the next level/line 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 074c8d20ccc..362fdf1973b 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 @@ -137,7 +137,11 @@ def test_set_destination_package_dest_location_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"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), ) for move in package_level.move_line_ids.mapped("move_id"): self.assertEqual(move.state, "done") @@ -235,7 +239,11 @@ def test_set_destination_line_dest_location_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"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), ) self.assertEqual(move_line.move_id.state, "done") self.assertEqual(move_line.picking_id.state, "assigned") @@ -276,7 +284,11 @@ def test_set_destination_line_partial_qty(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"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), ) self.assertEqual(move_line_c.move_id.state, "done") # Scan remaining qty (4/10) @@ -421,15 +433,12 @@ def test_set_destination_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"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), ) - # self.assert_response_start( - # response, - # # FIXME: message needs to be refactored to avoid "False" as location name - # message=self.service.msg_store.location_content_transfer_complete( - # self.env["stock.location"] - # ), - # ) def test_set_destination_line_split_move(self): """Scanned destination location valid for a move line, but related moves @@ -477,7 +486,13 @@ def test_set_destination_line_split_move(self): self.assertEqual(remaining_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(response, move_lines.mapped("picking_id")) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + ) # 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( From e90fef0222053f53eaf879e45924499806663e66 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:26:29 +0200 Subject: [PATCH 279/710] location transfer bknd: always include source loc in data --- shopfloor/services/location_content_transfer.py | 4 ++++ shopfloor/tests/test_location_content_transfer_base.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index cb0d9044f20..fd35f7905fc 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -125,7 +125,10 @@ def _data_content_all_for_location(self, pickings): sorter.feed_pickings(pickings) lines = sorter.move_lines() package_levels = sorter.package_levels() + location = pickings.mapped("move_line_ids.location_id") + assert len(location) == 1, "There should be only one src location at this stage" return { + "location": self.data.location(location), "move_lines": self.data.move_lines(lines), "package_levels": self.data.package_levels(package_levels), } @@ -876,6 +879,7 @@ def _schema_all(self): package_level_schema = self.schemas.package_level() move_line_schema = self.schemas.move_line() return { + "location": self.schemas._schema_dict_of(self.schemas.location()), # we'll display all the packages and move lines *without package # levels* "package_levels": self.schemas._schema_list_of(package_level_schema), diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index 33010fe9660..989f867b177 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -57,12 +57,14 @@ 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") self.assert_response( response, next_state=state, data={ "move_lines": self.data.move_lines(lines), "package_levels": self.data.package_levels(package_levels), + "location": self.data.location(location), }, message=message, ) From 22569b03647b6ce3db0eed9e0ccc1ef1d40443c5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:27:23 +0200 Subject: [PATCH 280/710] location transfer bknd: rollback move lines and fail if not assigned --- shopfloor/services/location_content_transfer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index fd35f7905fc..96013954105 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -292,6 +292,14 @@ def scan_location(self, barcode): new_moves = self._create_moves_from_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."), + } + ) pickings = new_moves.mapped("picking_id") move_lines = new_moves.move_line_ids From 6a29be91d6a96790991130cf8c9bfb33914f7fef Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:36:24 +0200 Subject: [PATCH 281/710] location transfer bknd: get rid of 'confirm_*' states --- .../services/location_content_transfer.py | 93 ++++++++----------- .../test_location_content_transfer_base.py | 42 ++++----- ...on_content_transfer_set_destination_all.py | 7 +- ...ransfer_set_destination_package_or_line.py | 14 ++- 4 files changed, 76 insertions(+), 80 deletions(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 96013954105..c2869d76922 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -51,29 +51,23 @@ 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, pickings, message=None): + def _response_for_scan_destination_all( + self, pickings, message=None, confirmation_required=False + ): """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(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. + If `confirmation_required` is set, + the client will ask to scan again the destination """ + data = self._data_content_all_for_location(pickings=pickings) + data["confirmation_required"] = confirmation_required + if confirmation_required and not message: + message = self.msg_store.need_confirmation() return self._response( - next_state="confirm_scan_destination_all", - data=self._data_content_all_for_location(pickings=pickings), - message=message, + next_state="scan_destination_all", data=data, message=message, ) def _response_for_start_single(self, pickings, message=None): @@ -94,30 +88,19 @@ def _response_for_start_single(self, pickings, message=None): message=message, ) - def _response_for_scan_destination(self, location, next_content, message=None): + def _response_for_scan_destination( + self, location, next_content, message=None, confirmation_required=False + ): """Transition to the 'scan_destination' state The client screen shows details of the package level or move line to move. """ + data = self._data_content_line_for_location(location, next_content) + 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=self._data_content_line_for_location(location, next_content), - message=message, - ) - - 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, + next_state="scan_destination", data=data, message=message, ) def _data_content_all_for_location(self, pickings): @@ -381,7 +364,9 @@ def set_destination_all(self, location_id, barcode, confirmation=False): ): # 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) + return self._response_for_scan_destination_all( + pickings, confirmation_required=True + ) self._set_destination_lines(pickings, move_lines, scanned_location) @@ -562,8 +547,8 @@ def set_destination_package( ) if not scanned_location.is_sublocation_of(package_level.location_dest_id): if not confirmation: - return self._response_for_confirm_scan_destination( - location, package_level + return self._response_for_scan_destination( + location, package_level, confirmation_required=True ) package_move_lines = package_level.move_line_ids package_moves = package_move_lines.mapped("move_id") @@ -626,7 +611,9 @@ def set_destination_line( ) if not scanned_location.is_sublocation_of(move_line.location_dest_id): if not confirmation: - return self._response_for_confirm_scan_destination(location, move_line) + return self._response_for_scan_destination( + location, move_line, confirmation_required=True + ) 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 @@ -876,10 +863,8 @@ 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 @@ -892,6 +877,11 @@ def _schema_all(self): # levels* "package_levels": self.schemas._schema_list_of(package_level_schema), "move_lines": self.schemas._schema_list_of(move_line_schema), + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, } @property @@ -902,6 +892,11 @@ def _schema_single(self): # we'll have one or the other... "package_level": self.schemas._schema_dict_of(schema_package_level), "move_line": self.schemas._schema_dict_of(schema_move_line), + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, } def start_or_recover(self): @@ -915,13 +910,7 @@ def scan_location(self): ) def set_destination_all(self): - return self._response_schema( - next_states={ - "start", - "scan_destination_all", - "confirm_scan_destination_all", - } - ) + return self._response_schema(next_states={"start", "scan_destination_all"}) def go_to_single(self): return self._response_schema(next_states={"start", "start_single"}) @@ -937,14 +926,10 @@ def scan_line(self): ) def set_destination_package(self): - return self._response_schema( - next_states={"start_single", "scan_destination", "confirm_scan_destination"} - ) + 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", "confirm_scan_destination"} - ) + return self._response_schema(next_states={"start_single", "scan_destination"}) def postpone_package(self): return self._response_schema(next_states={"start_single"}) diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index 989f867b177..0bf62b4ef7c 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -50,7 +50,7 @@ def assert_response_start(self, response, message=None): self.assert_response(response, next_state="start", message=message) def _assert_response_scan_destination_all( - self, state, response, pickings, message=None + self, state, response, pickings, message=None, confirmation_required=False ): # this code is repeated from the implementation, not great, but we # mostly want to ensure the selection of pickings is right, and the @@ -65,20 +65,20 @@ def _assert_response_scan_destination_all( "move_lines": self.data.move_lines(lines), "package_levels": self.data.package_levels(package_levels), "location": self.data.location(location), + "confirmation_required": confirmation_required, }, 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 + def assert_response_scan_destination_all( + self, response, pickings, message=None, confirmation_required=False ): self._assert_response_scan_destination_all( - "confirm_scan_destination_all", response, pickings, message=message + "scan_destination_all", + response, + pickings, + message=message, + confirmation_required=confirmation_required, ) def assert_response_start_single(self, response, pickings, message=None): @@ -93,24 +93,22 @@ def assert_response_start_single(self, response, pickings, message=None): ) def _assert_response_scan_destination( - self, state, response, next_content, message=None + self, state, response, next_content, message=None, confirmation_required=False ): location = next_content.location_id + data = self.service._data_content_line_for_location(location, next_content) + data["confirmation_required"] = confirmation_required 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 + response, next_state=state, data=data, message=message, ) - def assert_response_confirm_scan_destination( - self, response, next_content, message=None + def assert_response_scan_destination( + self, response, next_content, message=None, confirmation_required=False ): self._assert_response_scan_destination( - "confirm_scan_destination", response, next_content, message=message + "scan_destination", + response, + next_content, + message=message, + confirmation_required=confirmation_required, ) 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 c40f5662501..35903b2dbfa 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -104,7 +104,12 @@ def test_set_destination_all_dest_location_need_confirm(self): "barcode": self.shelf2.barcode, }, ) - self.assert_response_confirm_scan_destination_all(response, self.pickings) + self.assert_response_scan_destination_all( + response, + self.pickings, + message=self.service.msg_store.need_confirmation(), + confirmation_required=True, + ) def test_set_destination_all_dest_location_confirmation(self): """Scanned dest. location != child but in picking type location: confirm 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 362fdf1973b..b46855f2702 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 @@ -120,8 +120,11 @@ def test_set_destination_package_dest_location_to_confirm(self): "barcode": self.env.ref("stock.stock_location_14").barcode, }, ) - self.assert_response_confirm_scan_destination( - response, package_level, + self.assert_response_scan_destination( + response, + package_level, + message=self.service.msg_store.need_confirmation(), + confirmation_required=True, ) def test_set_destination_package_dest_location_ok(self): @@ -223,7 +226,12 @@ def test_set_destination_line_dest_location_to_confirm(self): "barcode": self.env.ref("stock.stock_location_14").barcode, }, ) - self.assert_response_confirm_scan_destination(response, move_line) + self.assert_response_scan_destination( + response, + move_line, + message=self.service.msg_store.need_confirmation(), + confirmation_required=True, + ) def test_set_destination_line_dest_location_ok(self): """Scanned destination location valid, moves set to done.""" From 78e2543a72380dcb3a701f9bf2fcc74204a54ace Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 16 Jul 2020 16:33:58 +0200 Subject: [PATCH 282/710] location transfer bknd: expose pkg lvl picking --- shopfloor/actions/data.py | 1 + shopfloor/services/schema.py | 1 + shopfloor/tests/test_actions_data.py | 1 + 3 files changed, 3 insertions(+) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 09ead326eb1..b5f3e306228 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -159,6 +159,7 @@ def _package_level_parser(self): return [ "id", "is_done", + ("picking_id:picking", self._simple_record_parser()), ("package_id:package_src", self._package_parser), ("location_dest_id:location_dest", self._location_parser), ( diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index c7d78c39e38..ac610977ade 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -157,6 +157,7 @@ def package_level(self): return { "id": {"required": True, "type": "integer"}, "is_done": {"type": "boolean", "nullable": False, "required": True}, + "picking": self._schema_dict_of(self._simple_record()), "package_src": self._schema_dict_of(self.package()), "location_src": self._schema_dict_of(self.location()), "location_dest": self._schema_dict_of(self.location()), diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 4be811ac455..fbd1942025d 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -149,6 +149,7 @@ def test_data_package_level(self): expected = { "id": package_level.id, "is_done": False, + "picking": self.picking.jsonify(["id", "name"])[0], "package_src": self._expected_package(package_level.package_id), "location_dest": self._expected_location(package_level.location_dest_id), "location_src": self._expected_location( From 22d5f6d1d3d82b7e36a482c29b68766b7b445f74 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 17 Jul 2020 11:12:26 +0200 Subject: [PATCH 283/710] location transfer bknd: fix scan dest message --- shopfloor/services/location_content_transfer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index c2869d76922..ba1a0b69ecb 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -79,9 +79,7 @@ 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=self.msg_store.location_content_transfer_complete(location) - ) + return self._response_for_start(message=message) return self._response( next_state="start_single", data=self._data_content_line_for_location(location, next_content), @@ -373,7 +371,9 @@ def set_destination_all(self, location_id, barcode, confirmation=False): return self._response_for_start( message={ "message_type": "success", - "body": _("Content transferred from {}.").format(location.name), + "body": _("Content transferred from {} to {}.").format( + location.name, scanned_location.name + ), } ) From 33490c095567d6efe7f23ffb1966690d39b935f8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 17 Jul 2020 11:35:20 +0200 Subject: [PATCH 284/710] location transfer bknd: fix scan dest message tests --- shopfloor/actions/message.py | 6 +++--- shopfloor/services/location_content_transfer.py | 9 +++------ ...ocation_content_transfer_set_destination_all.py | 14 ++++++-------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 300a1af6793..9333acb9526 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -305,11 +305,11 @@ def location_content_transfer_item_complete(self, location_dest): "body": _("Content transfer to {} completed").format(location_dest.name), } - def location_content_transfer_complete(self, location): + def location_content_transfer_complete(self, location_src, location_dest): return { "message_type": "success", - "body": _("Location Content Transfer from {} complete").format( - location.name + "body": _("Content transferred from {} to {}.").format( + location_src.name, location_dest.name ), } diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index ba1a0b69ecb..c3faf644548 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -369,12 +369,9 @@ def set_destination_all(self, location_id, barcode, confirmation=False): self._set_destination_lines(pickings, move_lines, scanned_location) return self._response_for_start( - message={ - "message_type": "success", - "body": _("Content transferred from {} to {}.").format( - location.name, scanned_location.name - ), - } + message=self.msg_store.location_content_transfer_complete( + location, scanned_location + ) ) def go_to_single(self, location_id): 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 35903b2dbfa..fb2c21b876b 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -72,10 +72,9 @@ def test_set_destination_all_dest_location_ok(self): ) self.assert_response_start( response, - message={ - "message_type": "success", - "body": "Content transferred from {}.".format(self.content_loc.name), - }, + message=self.service.msg_store.location_content_transfer_complete( + self.content_loc, sub_shelf1 + ), ) self.assert_all_done(sub_shelf1) @@ -129,10 +128,9 @@ def test_set_destination_all_dest_location_confirmation(self): ) self.assert_response_start( response, - message={ - "message_type": "success", - "body": "Content transferred from {}.".format(self.content_loc.name), - }, + message=self.service.msg_store.location_content_transfer_complete( + self.content_loc, self.shelf2 + ), ) self.assert_all_done(self.shelf2) From 2b08322ba95a4f18f6f454a4e0a63b92c79015e5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 15 Jul 2020 14:01:17 +0200 Subject: [PATCH 285/710] Improve shopfloor menu views * Add archiving * Add form view, hidden by default, but modules can remove the 'editable' option of the tree view to enable it if they need more options --- shopfloor/models/shopfloor_menu.py | 1 + shopfloor/views/shopfloor_menu.xml | 51 ++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 51f97a56ff1..98d503229fb 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -32,6 +32,7 @@ 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.", ) + active = fields.Boolean(default=True) def _selection_scenario(self): return [ diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 68759df75b8..177b271a316 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -26,6 +26,47 @@ + + shopfloor menu form + shopfloor.menu + +
+ + + +
+
+
shopfloor menu search shopfloor.menu @@ -33,8 +74,14 @@ - + + +
@@ -42,6 +89,6 @@ Menus shopfloor.menu ir.actions.act_window - tree + tree,form From f405c50a4b502374c13c674245a01127dde5ef9f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 16 Jul 2020 16:17:38 +0200 Subject: [PATCH 286/710] 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 7a55e5ba991..6fc648282aa 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 88fe45175d1..c7dba785a3e 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 177b271a316..149d4cea7bd 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -57,11 +57,13 @@ /> - - + > + + + From 7778df2413eaf335f9dab642a1b2070f93c9d22a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 13 Jul 2020 15:51:50 +0200 Subject: [PATCH 287/710] 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 c7dba785a3e..179179a6df8 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 e2ec2cff505d818168c9b58e0da6068e40820db4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jul 2020 09:58:02 +0200 Subject: [PATCH 288/710] 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 179179a6df8..f805e058196 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 ec54b81e9c516eb332d4f00cc52ac715192a3d99 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Jul 2020 12:52:57 +0200 Subject: [PATCH 289/710] 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 9bd822d1528..53eb9eb88b4 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 fb802c3099ac95008991a40ea09a17643176a8f3 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 24 Jun 2020 16:00:41 +0200 Subject: [PATCH 290/710] 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 b5f3e306228..2ff2cbdd2bb 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 4929c516c04..036a20b724e 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 + 35 + zone_picking + + Cluster Picking 30 diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index 2dbedd83ad6..c912a1087d5 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -14,6 +14,21 @@ + + Zone Picking + ZPI + + + + + + + + + internal + + + Cluster Picking CPI diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 98d503229fb..f0385f57173 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 29c96da23d4..7bb68b899c8 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 09470e83eeb..166173654b6 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 f805e058196..b065ad02f18 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 ac610977ade..f686a61695b 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 00000000000..99172cfdd7f --- /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 61dfbc481b4..f55c20ab3cb 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 00000000000..9a5b9f895a4 --- /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 725d27573036d98a7dabcc7495b6ee80aa801b6c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 7 Jul 2020 09:34:24 +0200 Subject: [PATCH 291/710] 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 99172cfdd7f..ea5636c7e01 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 915bf494138511cc8deec1360b11da5c7cea75ce Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 7 Jul 2020 11:15:10 +0200 Subject: [PATCH 292/710] 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 ea5636c7e01..12a7629b0af 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 cb969090502f970443ccba5df013ae057b1e72a3 Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 17 Jul 2020 09:08:10 +0200 Subject: [PATCH 293/710] 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 2ff2cbdd2bb..10f04f8269d 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 f686a61695b..59c085c033f 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 12a7629b0af..d433eeef387 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 f55c20ab3cb..a4791dcc17a 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 9a5b9f895a4..1a63f61aad3 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 00000000000..b339bbd8932 --- /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 e874db7967ca1c199a3d3ab3079f449f59819090 Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 17 Jul 2020 15:58:05 +0200 Subject: [PATCH 294/710] 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 d433eeef387..b3df881ef75 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 a4791dcc17a..c100ca244a7 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 1a63f61aad3..5b708639a1b 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 00000000000..a07ee6be9b5 --- /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 ab34e7a91dcf78908302e5fcb801e21cc3af22fe Mon Sep 17 00:00:00 2001 From: sebalix Date: Sat, 18 Jul 2020 13:24:56 +0200 Subject: [PATCH 295/710] 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 9333acb9526..8c422b7e333 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 b3df881ef75..2d015fc31ad 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 c100ca244a7..90a93e7bbe4 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 5b708639a1b..ed857bb3496 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 00000000000..e9142500cc5 --- /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 ae602a5974aa04dbef57e69ac0fea3d5a52b11a4 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 21 Jul 2020 10:39:28 +0200 Subject: [PATCH 296/710] 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 8c422b7e333..6a3be134501 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 1bfd3ece74b..05a95579d3f 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 53eb9eb88b4..db4672b573c 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 00000000000..0194457f9dd --- /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 b065ad02f18..f7577f7d959 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 c3faf644548..02cfe4683d6 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 2d015fc31ad..167947ec3b1 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 90a93e7bbe4..82123a252da 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 3c097a4ee81..5f550f7205b 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 ed857bb3496..d0b18cba637 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 00000000000..88568e70fd9 --- /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 185069044e32e8a9cdbdef5d6220e9e9e0bb6d56 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 27 Jul 2020 11:07:50 +0200 Subject: [PATCH 297/710] 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 167947ec3b1..35d61bbdc85 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 82123a252da..62f5b637e59 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 00000000000..dba09b2c706 --- /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 59830a94f3405159cc82721a66fa72326dd90700 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 27 Jul 2020 13:17:22 +0200 Subject: [PATCH 298/710] 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 35d61bbdc85..227d50c2e52 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 62f5b637e59..373328a6fcb 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 00000000000..3767f02905d --- /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 f5a13f8f16ab8d0b7bff1f330bd2df81d80cbd7f Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 28 Jul 2020 17:49:01 +0200 Subject: [PATCH 299/710] 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 6a3be134501..0f72b69ecdb 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 00000000000..bf6cb12f755 --- /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 f7577f7d959..0871afb452b 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 227d50c2e52..1c51b2f3cb2 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 373328a6fcb..2dea4bfe54d 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 f68772726b8..1f924f10b31 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 d0b18cba637..79d22c8ea7c 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 00000000000..06f6047342c --- /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 82a96c4af75dfcf1161c92b58ebfda13dcb1c23b Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 29 Jul 2020 16:17:55 +0200 Subject: [PATCH 300/710] 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 1c51b2f3cb2..d12ee07ad28 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 79d22c8ea7c..8031c291dae 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 e9142500cc5..fc442b851e6 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 600e01625d7249d017239d429b748a197fa52d28 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 30 Jul 2020 11:56:02 +0200 Subject: [PATCH 301/710] 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 0f72b69ecdb..f8b02276a63 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 d12ee07ad28..d911a2fbf86 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 2dea4bfe54d..1c0b0f65e8b 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 00000000000..ca9c7b37b0f --- /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 f6421a8d59b59b9b49b99c789459032766faeddf Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 30 Jul 2020 15:14:20 +0200 Subject: [PATCH 302/710] 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 10f04f8269d..9d4d045d663 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 7e577ba9cc5..4e25abbe424 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 59c085c033f..62d4647f700 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 03fea1c23a7..8f8e283c854 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 fbd1942025d..6ea9f5f984d 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 0fef6ab21f1..c3315a5f2d5 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 18baad889692aab7e08ca0728a08d65e6f0c1fc0 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 30 Jul 2020 18:00:12 +0200 Subject: [PATCH 303/710] 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 f8b02276a63..91bbd908d45 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 d911a2fbf86..793ebe4b6c6 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 1c0b0f65e8b..e7a0ace7586 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 8031c291dae..3d66f32bfa9 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 00000000000..2cff790f7b2 --- /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 2eb2044b3b67dad043cb1c405a41e021c4609daf Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 31 Jul 2020 09:25:52 +0200 Subject: [PATCH 304/710] 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 793ebe4b6c6..edb9feac100 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 2cff790f7b2..3666e151415 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 bd549962cfef52fcae398d78bce7666f1dc8a1e1 Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 31 Jul 2020 14:06:35 +0200 Subject: [PATCH 305/710] 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 edb9feac100..565b53e215c 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 e7a0ace7586..8f64051db90 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 3d66f32bfa9..1b9d28a6e0c 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 00000000000..985b6260ee8 --- /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 5bab029f663e6bfd008c67c81b7e1d66517063f1 Mon Sep 17 00:00:00 2001 From: sebalix Date: Fri, 31 Jul 2020 14:44:22 +0200 Subject: [PATCH 306/710] 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 565b53e215c..1d4e8145462 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 1b9d28a6e0c..1f66fde1671 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 3666e151415..86ebbc4ed9d 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 985b6260ee8..38492df9798 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 ca9c7b37b0f..e6b67d7889d 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 94ed6769329e407d3c267bcd5547d65b00aaf94d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 31 Jul 2020 17:33:59 +0200 Subject: [PATCH 307/710] 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 9d4d045d663..dc156516f79 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 62d4647f700..cf0689f4809 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 8f8e283c854..077df973442 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 1d4e8145462..a2eadd1a92b 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 6ea9f5f984d..7249d1874e9 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 c3315a5f2d5..54f280bbc1d 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 e5d21d8fa39..fe81fbb366a 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 1f66fde1671..ddd2897411c 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 88568e70fd9..bf2c13f50ff 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 f3963f09b7da42de083fff9f5cded921e91399f6 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 10 Aug 2020 11:20:43 +0200 Subject: [PATCH 308/710] 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 dc156516f79..f2d7d1bd768 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 feb27307de8..fa898748ec0 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 7249d1874e9..877883a99a0 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 54f280bbc1d..22821ee0a58 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 0fc93b7674f..5f79c6ec40d 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 c708ec47ea8..26ab3399478 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 64a67fed2009cdbdda2d28b7a1d8af8967159374 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 10 Aug 2020 12:44:24 +0200 Subject: [PATCH 309/710] 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 877883a99a0..efa988a2b16 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 8df8304be4e8bc29b930a149a909606b5f24bcd9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 09:43:46 +0200 Subject: [PATCH 310/710] 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 2306432b188..0982093dc07 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 aec7b9ca9ac..c98b8870cfa 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 ef4d08d28369c798da8c897a855c004747fff6a4 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 15:00:39 +0200 Subject: [PATCH 311/710] 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 f0385f57173..dd3f3de93d6 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 cbe326fc8da..042ef4650cb 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 90c8ef245d5e4ce6b39ea6ffdbbefc4ac3192e8d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 15:03:50 +0200 Subject: [PATCH 312/710] 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 91bbd908d45..4f46fb111c9 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 8a9c560e4edb883cb2bba1eebc7e7353ff0624b9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 15:50:42 +0200 Subject: [PATCH 313/710] 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 0982093dc07..91a098355f1 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 50209debdca72b2c6d8f4925e70a6c49b9b2537f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 16:53:20 +0200 Subject: [PATCH 314/710] 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 efa988a2b16..0e9d12532e3 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 601acf6b9a49d9aa17dd84cd93f4bd9c2b486bab Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Thu, 13 Aug 2020 10:15:22 +0200 Subject: [PATCH 315/710] 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 2d0d521699b..7f89d4184a2 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 05a95579d3f..cb9e4bf257c 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 00000000000..b9a8986af05 --- /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 cdcd93cd080..3549f226ad0 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 042ef4650cb..600fa3e58b6 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 00000000000..3152277af67 --- /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 ad90a59d5b9..81b809c27a6 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 00000000000..308071f2664 --- /dev/null +++ b/shopfloor/views/stock_picking_views.xml @@ -0,0 +1,34 @@ + + + + stock.picking.shopfloor.form + stock.picking + + + + + + + + + + + + + + + + + From 65f6d529c318742c4a7b893fd80187a14d09a075 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 13 Aug 2020 17:44:04 +0200 Subject: [PATCH 316/710] 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 3549f226ad0..a47b4d87ec1 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 fa898748ec0..4508b32fa1f 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 e0063f515dd..94b4b5d8904 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 e82ac9f9afe..93c76704858 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 24f2dabe66fd928374826e19e9f8e3d01a469373 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 09:31:10 +0200 Subject: [PATCH 317/710] 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 9b2beca215f..a7572575834 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 c54d6178d7c5fe5b4f843b50f16864f5f6f2f773 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 09:35:28 +0200 Subject: [PATCH 318/710] 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 a2eadd1a92b..81292dd388e 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 8c52a2ea6f62cfa54029f0126fb17d7fcdc1dfa2 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 11:16:18 +0200 Subject: [PATCH 319/710] 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 8f64051db90..b17bbc0db04 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 00000000000..176e6d94ead --- /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 f557b3b0162ca24185975856007abaea5448ba36 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 11:21:40 +0200 Subject: [PATCH 320/710] 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 02cfe4683d6..5ebb6d7c936 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 1f2ef10548cfb94a601cc9673b8ce8af72482b94 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 15:10:07 +0200 Subject: [PATCH 321/710] 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 176e6d94ead..6ab2dd709ac 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 040092c8436fb5069a047baa5f8408fba87a1664 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 17 Aug 2020 15:20:36 +0200 Subject: [PATCH 322/710] 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 5ebb6d7c936..a0e98458996 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 708261f885138a7c2a63428c283228bf85295671 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 19 Aug 2020 15:42:35 +0200 Subject: [PATCH 323/710] 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 dd3f3de93d6..23b7f0bd9e6 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 5b4e12bfac2d53f612435892c2658a9e649b96a2 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 324/710] 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 f2d7d1bd768..b45da63a280 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 cf0689f4809..16da390bb20 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 81292dd388e..26135c74f06 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 ddd2897411c..097e7e0e157 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 7351f4dd6292378661b473ad5f1a4d1eac5be6de Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 20 Aug 2020 12:29:44 +0200 Subject: [PATCH 325/710] 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 26135c74f06..812f74a94e5 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 bf2c13f50ff..f1414d68235 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 491845a49c92d5d586dbeeeb0ba36870aae0eecb Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 20 Aug 2020 12:30:41 +0200 Subject: [PATCH 326/710] 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 812f74a94e5..762658029b9 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 52de4fa17f52c477cc03dc9a8b2d8704b98f1fb2 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 21 Aug 2020 13:23:05 +0200 Subject: [PATCH 327/710] 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 4508b32fa1f..e3f0ac7e6c1 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 942a756ba7470c83b7f8bdc3c606110567499eeb Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 21 Aug 2020 14:36:21 +0200 Subject: [PATCH 328/710] 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 bf6cb12f755..e1d9b078d28 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 0871afb452b..a1924e1fad0 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 a0e98458996..4a64454e89b 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 762658029b9..e95b00deed8 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 4f5e190a35a13dcc49c82a2897324f37b4b38660 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Aug 2020 07:46:34 +0200 Subject: [PATCH 329/710] 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 94b79e0ea13..93392bf283e 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 8bdf4d75cf9..21c46847de3 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 b9c84061a20693b5389da5f0e1887a0d6c172b88 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Aug 2020 07:48:01 +0200 Subject: [PATCH 330/710] 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 e95b00deed8..baccf9dbd82 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 eec0735c6858302bd1a79eccf9e5633cc6587a38 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Aug 2020 08:17:33 +0200 Subject: [PATCH 331/710] 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 c3bc49511db..b768c062800 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 b17bbc0db04..bfa6c153a48 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 00000000000..3e29f16b8ba --- /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 31bbd70701752e07746d2ebcb26cbdbd93e00b80 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 26 Aug 2020 12:10:09 +0200 Subject: [PATCH 332/710] 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 baccf9dbd82..d9f36bc3da1 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 b09159a917a0f43a975642c8a3aa15eabdd88f1e Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 18 Aug 2020 18:42:06 +0200 Subject: [PATCH 333/710] 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 4f46fb111c9..b713be668c3 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 0194457f9dd..f700f1bec74 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 7bb68b899c8..f92dedea7a0 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 4a64454e89b..5552dae16ac 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 b55dc7f2698..7569e5aa4b1 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 0bf62b4ef7c..6c89326b345 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 6ab2dd709ac..b4c6252dba6 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 00000000000..9a3ac8e852e --- /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 d4cc0609c4d516bdbc04b5a357846fafc2955812 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 26 Aug 2020 13:15:15 +0200 Subject: [PATCH 334/710] 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 b45da63a280..d8173033a87 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 cadfce79b11a90d22126fbe44c6ede652e18622c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 26 Aug 2020 15:41:10 +0200 Subject: [PATCH 335/710] 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 d9f36bc3da1..1150b685998 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 4208d70eb097e361541c04f83adc001b5cfaca15 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 26 Aug 2020 14:13:49 +0200 Subject: [PATCH 336/710] 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 5552dae16ac..02226267373 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 e889ec64019faa2a7ce824a8e827e24c9ce6095b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 26 Aug 2020 14:54:48 +0200 Subject: [PATCH 337/710] 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 a1924e1fad0..a1867cb7db0 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 91a098355f1..308a420b946 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 1150b685998..5e8956eb520 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 b458724d0b6feb7605dfe6e3c4e527779394f96a 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 338/710] 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 f700f1bec74..c641389dcb5 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 a47b4d87ec1..90e10fd131f 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 b46855f2702..f8fd7db29b1 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 c98b8870cfa..33972798301 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 f1414d68235..da2f0e110fe 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 86ebbc4ed9d..d9ab4eb0840 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 38492df9798..afd3a924658 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 4ecabaae664cc6fb2f4e6fddfb8057886a509c24 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 27 Aug 2020 14:48:12 +0200 Subject: [PATCH 339/710] 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 93392bf283e..7e30b71dd7c 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 244add8d1db502b94044eacefcf40d41760ac1db Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 28 Aug 2020 14:19:04 +0200 Subject: [PATCH 340/710] 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 d8173033a87..86343d1013a 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 16da390bb20..24d0480c8bc 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 0e9d12532e3..23cf1c41e9f 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 854c960912e93dd2787c1c20cbc9760b1b82e074 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 2 Sep 2020 10:44:07 +0200 Subject: [PATCH 341/710] 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 7f89d4184a2..6ae74a4daa4 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 4b45823471bbb8d74807f3aad08c2d68cf5448ba Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 2 Sep 2020 10:50:50 +0200 Subject: [PATCH 342/710] 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 6ae74a4daa4..4412ed12e6c 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 86343d1013a..b6f687999db 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 24d0480c8bc..d1d424c3a82 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 23cf1c41e9f..d0d61adaefc 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 63b2d6a25d899ac843310ecd358c4e95f69f4c8e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 28 Aug 2020 16:15:14 +0200 Subject: [PATCH 343/710] 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 ec9e66c7921..eb6b6e13098 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 785aa6231d1..7a95ac5506f 100644 --- a/shopfloor/views/shopfloor_profile_views.xml +++ b/shopfloor/views/shopfloor_profile_views.xml @@ -16,12 +16,19 @@
+ + @@ -35,6 +42,12 @@ + + From 68a1da435a571c855f8eb2d2f81220060b0235fc Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 31 Aug 2020 12:11:07 +0200 Subject: [PATCH 344/710] 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 b6f687999db..0ad4f88d102 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 d0d61adaefc..43bff8b1e3a 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 5bb7e33da1fc319ca334f1f2030a67842fce275c Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 27 Aug 2020 14:55:50 +0200 Subject: [PATCH 345/710] 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 5e8956eb520..16b850c0a97 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 d9ab4eb0840..7a37d012415 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 afd3a924658..2024d16524d 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 77ef3e7893913e6ef03c5cd600a03eb447c3e86c Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 27 Aug 2020 16:12:47 +0200 Subject: [PATCH 346/710] 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 16b850c0a97..3098e1b9c52 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 101a0e17d9a8df888579d9e9d7667d93e5f4cbcb Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 28 Aug 2020 14:05:05 +0200 Subject: [PATCH 347/710] 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 db4672b573c..eb8f6909ac5 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 a1867cb7db0..9d45a70b312 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 02226267373..31887876a18 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 308a420b946..f437449b057 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 3098e1b9c52..ad277ca0da3 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 6528eafe12b..1df4f0e293f 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 fb2c21b876b..fc33a4d742f 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 f8fd7db29b1..92e58bc17a3 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 097e7e0e157..05c019c4bc4 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 da2f0e110fe..f1639587810 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 2024d16524d..be32d2295a6 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 d76057d8f0944ef95fbb1852c948d9398d6288e4 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 31 Aug 2020 16:11:47 +0200 Subject: [PATCH 348/710] 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 f92dedea7a0..388cad6a1a3 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 9d45a70b312..aad0438fa7a 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 29bd1315e8e0f18d6aaacbf07ddda24f6dcb983c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 31 Aug 2020 16:45:40 +0200 Subject: [PATCH 349/710] 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 388cad6a1a3..fbc051f8eec 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 e3f0ac7e6c1..d96180a182e 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 93c76704858..845c6d8df00 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 5440e473d4b..99229c7e0de 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 a45230cddb17e1ddb02912cd9b10eac04773e3cd Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 1 Sep 2020 12:33:52 +0200 Subject: [PATCH 350/710] 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 c641389dcb5..1ba009e8cbe 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 90e10fd131f..7f1714e3747 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 92e58bc17a3..5f6815eb1f2 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 33972798301..72cdd8ba5db 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 f1639587810..9986f78489c 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 7a37d012415..8b29c746875 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 be32d2295a6..b1f01a6de80 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 b9bdd5e0d731798e3d881f4815fd525af7800f4d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 7 Sep 2020 15:39:48 +0200 Subject: [PATCH 351/710] 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 0ad4f88d102..5603890e76f 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 ed09de78ce894fb29f3aeaf62a269bd5f200680c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 8 Sep 2020 10:45:00 +0200 Subject: [PATCH 352/710] 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 1ba009e8cbe..acf7371576f 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 5f6815eb1f2..05432437049 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 e31200c2a75768d21c20c396412324373d0eef08 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 8 Sep 2020 13:29:22 +0200 Subject: [PATCH 353/710] 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 154dedb2813..b404b318c3b 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 738b508cead16bf3f1e172cfb5c26d08ba7fd3ec Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 9 Sep 2020 07:13:33 +0200 Subject: [PATCH 354/710] 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 b404b318c3b..1878e45fe82 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 aad0438fa7a..d9f5f464d17 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 4ba9e0fa1427c84de64ede1ceb3c5af1c859e076 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 Sep 2020 08:31:17 +0200 Subject: [PATCH 355/710] 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 b713be668c3..51dedf614ce 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 18e9681be0e..69679cfa675 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 7e30b71dd7c..720fe12f817 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -381,3 +381,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 264510337b7..97524254bf7 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 558cb1469e7f5a847187583f368cf1870d566c0a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 Sep 2020 08:32:23 +0200 Subject: [PATCH 356/710] 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 d96180a182e..40ff628e8b6 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 bba6f7af586..c5ff9e92402 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 442ab0ae14c..3458b76e06c 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 2d765930a80..b7d71cc2714 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 c75a20b9c4021cce541b25fdae87ec4c196f24d3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 Sep 2020 10:34:19 +0200 Subject: [PATCH 357/710] 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 69679cfa675..f3263e25634 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 720fe12f817..3cde310cc03 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -392,7 +392,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 545a4db4357..056557d4071 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 14b214ef82a..ce545658b47 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 f7f94885d70ff133171ec8d80c2d6f86ec21646b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 Sep 2020 11:17:32 +0200 Subject: [PATCH 358/710] 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 f3263e25634..debbea4126f 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 3cde310cc03..022355805f7 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -382,17 +382,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 92ca3f1045defe74cc383d0e77944428da07b341 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 11 Sep 2020 11:18:37 +0200 Subject: [PATCH 359/710] 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 31887876a18..a2dfcdd88b3 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 c213c8df9ff8e0ee58b09f3f8735c04e65da3f46 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 14 Sep 2020 10:23:45 +0200 Subject: [PATCH 360/710] 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 ad277ca0da3..e67a521597c 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 b339bbd8932..c8a6fc7933e 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 265ccbb84c21912c229b644b0a35fa6ead5a6f0c Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 16 Sep 2020 10:55:41 +0200 Subject: [PATCH 361/710] 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 b4c6252dba6..2b440f5202f 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 276df52ea3e9be7fb8bd0308b3537313b08eaa0f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 2 Sep 2020 13:32:58 +0200 Subject: [PATCH 362/710] 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 42f45d7eeee..7aa11fa8093 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 76eac66eba3..b094c2c812e 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 e1d9b078d28..5d96a56ea22 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 d9f5f464d17..3ba0e100664 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 e67a521597c..b9e5d2f67e2 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 9b3575011cceb3f9d1eb9e79d2c52a3793ea8b2e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 17 Sep 2020 07:42:55 +0200 Subject: [PATCH 363/710] 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 5f79c6ec40d..f9488e67148 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 486adebba0077c313aac9b0f8971acf067765cf5 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 364/710] 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 acf7371576f..da24e18d02b 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 bfa6c153a48..ca805134349 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 9a3ac8e852e..b39e22878f9 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 405989a76a57feff615ab7fc406721038b859149 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 9 Sep 2020 12:18:43 +0200 Subject: [PATCH 365/710] 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 f437449b057..d6212e46be6 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 72cdd8ba5db..170321fee0f 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 7800411715535aa748633cede12879d92164b200 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 17 Sep 2020 13:28:38 +0200 Subject: [PATCH 366/710] 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 d6212e46be6..46f55262255 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 170321fee0f..298ab080c2a 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 639185798263ec9f1cce9003daa08c18975f7ea5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 17 Sep 2020 12:58:17 +0200 Subject: [PATCH 367/710] 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 b9e5d2f67e2..d51ae01c0e9 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 9986f78489c..33fc91473c3 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 8b29c746875..05c6f8e16e5 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 b1f01a6de80..a27e4356afb 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 6f1e6189a71d7a802a8cd4bb02f0f7865a20903e Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 17 Sep 2020 09:37:28 +0200 Subject: [PATCH 368/710] 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 a2dfcdd88b3..4d28698ff60 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 05432437049..2e5b9c27aab 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 7e8ace5f431a80ab3a27c902861afe383f3c44b3 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 17 Sep 2020 17:21:45 +0200 Subject: [PATCH 369/710] 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 da24e18d02b..f1690dd1820 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 7f1714e3747..5f881104578 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 298ab080c2a..041aa51b56c 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 b39e22878f9..a0554d6ea1a 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 32c3c7dc6bdfd4ec6009f07c21712dc0a20b6f5b Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 21 Sep 2020 15:36:46 +0200 Subject: [PATCH 370/710] 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 4d28698ff60..f9ebfc3d1d0 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 6c89326b345..40e24385c53 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 fc33a4d742f..93cd3d80bcc 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 2e5b9c27aab..6212307b49a 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 961dd74a3413fd5e40dd3a3e787b8baf2dc1e185 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 22 Sep 2020 08:42:41 +0200 Subject: [PATCH 371/710] 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 4412ed12e6c..b1d12b54fb4 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 1878e45fe82..94d449aa68c 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 00000000000..8255f2c535d --- /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 cb9e4bf257c..30930739fd4 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 00000000000..41fa8779617 --- /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 fbc051f8eec..8028b5cff05 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 1aa691c6e95..24b1b8a5310 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 3ba0e100664..0041c5af23c 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 67ee61d0021..348a0fdb93a 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 23fea457addc3de3bb25f1f347c11080396b16d7 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 17 Sep 2020 19:34:27 +0200 Subject: [PATCH 372/710] 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 94d449aa68c..49b45430dc2 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 51dedf614ce..00724daf907 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 f9ebfc3d1d0..3e3e34cd9c8 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 599b6aabd7f..cdbf6efc497 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 cefc8f655a859a9f0075f25cad298f99bf7e4ffb Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 24 Sep 2020 15:13:50 +0200 Subject: [PATCH 373/710] 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 d1d424c3a82..4d8742930b0 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 467faa034ad823125fd54b76681302840a07e938 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 25 Sep 2020 07:56:00 +0200 Subject: [PATCH 374/710] 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 8028b5cff05..f0894812032 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 40ff628e8b6..6ff2d962aa1 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 0041c5af23c..660d0dc7828 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 12df01d720a1d890aa46ec2fc2c69e1fd0a64401 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 25 Sep 2020 08:43:26 +0200 Subject: [PATCH 375/710] 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 ca805134349..d2b61ffdf35 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 25672dcf5fb2452e8125f6f143472c3e6df92a6d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 25 Sep 2020 10:36:55 +0200 Subject: [PATCH 376/710] 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 5f881104578..f42bed1277d 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 d2b61ffdf35..571ed4a34f1 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 00000000000..7f387f7d6b3 --- /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 2a4c2743ecfd85681a29739e0c26c442fccca9bd Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 25 Sep 2020 07:59:38 +0200 Subject: [PATCH 377/710] 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 f195c05d118..c0a32de3fe7 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 660d0dc7828..5edecae6f65 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 8d1eaa0a83c..130319cea73 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 015ffc082c0f174c43544191fcbc0ff34f029b5f 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 378/710] 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 eb8f6909ac5..9641cec1a01 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 d51ae01c0e9..d36e5ffd097 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 05c019c4bc4..f2cb9323822 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 fc442b851e6..ba02eac993a 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 3f29616a357e18e1ce5b2b567c5dac494f7aee6c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 5 Oct 2020 11:04:31 +0200 Subject: [PATCH 379/710] 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 d36e5ffd097..602e387cfc6 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 f2cb9323822..948ec31161a 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 ba02eac993a..614cd6a3ff2 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 7b89a536cda8e12a9930b15df4e49191d68d0a29 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 7 Oct 2020 08:50:13 +0200 Subject: [PATCH 380/710] 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 23b7f0bd9e6..0a16a91ab51 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 149d4cea7bd..7777787659c 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 8edc9603220bebb620104436aeefc522a01e38ea Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 7 Oct 2020 11:17:38 +0200 Subject: [PATCH 381/710] 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 00724daf907..e084794e862 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 24b1b8a5310..a8569cdc3dd 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 3e3e34cd9c8..abc5dfeee2a 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 66786e1023d..62c4a6f6941 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 312c95a18fb6a995baaa117d13491866af4c0415 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 7 Oct 2020 14:10:23 +0200 Subject: [PATCH 382/710] 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 46f55262255..5f67f6c6d4b 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 041aa51b56c..9057f517554 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 00000000000..8f04301fc9e --- /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 ac472ec9ede48dfc16090f5bcea6de4f0c006761 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 7 Oct 2020 14:39:37 +0200 Subject: [PATCH 383/710] 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 7aa11fa8093..464f279228e 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 00000000000..9e031de542c --- /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 abc5dfeee2a..88e0762ea38 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 5f67f6c6d4b..0af66ebb2d0 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 68dd64dd2afa8cf46bfbbddd9fb2123dfc4d83ce Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 7 Sep 2020 11:04:28 +0200 Subject: [PATCH 384/710] 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 5d96a56ea22..2537cea5b50 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 c0a32de3fe7..a46bee4bd8e 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 e084794e862..1b5cc7f559e 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 f0894812032..b9146dc8aa6 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 a8569cdc3dd..84822602e4d 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 b768c062800..f45e08e9202 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 5edecae6f65..5cfc44e760e 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 88e0762ea38..9a92d0c28f6 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 571ed4a34f1..575ef7fba12 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 7569e5aa4b1..383a9d10683 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 00000000000..4f8f4c08526 --- /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 1f924f10b31..190b8f9c41e 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 06f6047342c..8818f683913 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 402dc2dd4d0f930978617a74b18e3715294cb8a5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 9 Oct 2020 15:58:15 +0200 Subject: [PATCH 385/710] 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 fe0cc8cdee2..059a7dda317 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 6fc648282aa..4d4d24ed1bb 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 00000000000..dd789bcc35f --- /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 756a0db67d6..6242bc91fc4 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 756a0db67d6..e69de29bb2d 100644 --- a/shopfloor/readme/ROADMAP.rst +++ b/shopfloor/readme/ROADMAP.rst @@ -1 +0,0 @@ -write me From 2e32aa4a0e3548086272e9304bc050258cd54828 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 9 Oct 2020 16:24:17 +0200 Subject: [PATCH 386/710] 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 b1d12b54fb4..899cf0321bb 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 b094c2c812e..fb7eb2ae123 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 2537cea5b50..c21e8e92e8a 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 16cab4a0cf1..a8fec75f2ef 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 5603890e76f..5301f902456 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 4e25abbe424..2bef510cbfe 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 a46bee4bd8e..f3f54f97702 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 49b45430dc2..1ad4aa1f8b1 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 1b5cc7f559e..e600f00c4ea 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 9e031de542c..96daa8fe61f 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 a7572575834..922df080400 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 3f04afa2409..86676e235d2 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 8255f2c535d..ef555c78c07 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 41fa8779617..80832f6d7e8 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 b9a8986af05..35111600bbb 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 a17e9a14321..9a0401ceddc 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 0a16a91ab51..9429a8c142d 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 eb6b6e13098..2d490b3b3f5 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 3581a18dce0..2e23d7b3244 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 9641cec1a01..bb4f74f099f 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 f1690dd1820..2794cf4449f 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 b9146dc8aa6..bd8b2c3de3a 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 84822602e4d..8dd4aea84a8 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 f42bed1277d..35cb047cc9f 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 07bf57a0990..f16aa11da15 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 600fa3e58b6..2525db4aaa7 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 77c66673596..d20af1278e9 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 f45e08e9202..8f7ee23462d 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 d9dcab6ef98..d0d2be4704d 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 6ff2d962aa1..4bf0b095b66 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 5cfc44e760e..ed3a1b77e74 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 debbea4126f..74cf09bce6e 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 9a92d0c28f6..4e296d82a81 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 3d9bad9d988..3fce4f2182f 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 44de8eb7e81..836efe4cbd5 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 af9b9d28411..55d2297937e 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 de8b24dc1f3..a943fc5c654 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 4d8742930b0..bb9071e9d77 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 077df973442..f57d40e33f4 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 022355805f7..c0242b89a45 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 0af66ebb2d0..e264be477aa 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 21c46847de3..b26d0113754 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 602e387cfc6..8201eaec441 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 383a9d10683..88bddd0ba2d 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 4f8f4c08526..967729f0445 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 43bff8b1e3a..6215ea5e8ec 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 22821ee0a58..1595c0af855 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 d45965861ec..11088781ade 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 25cbdc1dc51..4c6ffe65c74 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 108df0e03c3..a18adba265b 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 f9488e67148..b3bcf36aa60 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 c5ff9e92402..2b675ed565b 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 fe81fbb366a..e32fcf16d6a 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 7e62c0f3f06..a6a038f72f0 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 f997ccad18c..72420778258 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 672bfec4035..1af8473da20 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 94b4b5d8904..bedc37a8bf6 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 26ab3399478..948e3358405 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 3458b76e06c..60968810b17 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 c84d5bed9dd..477fed1f3f5 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 845c6d8df00..151beb76e2b 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 99229c7e0de..b49b1aed5eb 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 b7d71cc2714..6fd3636f26a 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 2fd44ecb464..093322dc1f0 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 b7a2e915fcb..fc95b63af72 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 190b8f9c41e..966816fbaf8 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 5f550f7205b..000aeb6b99f 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 1b86cdf6909..48c6d9f60e6 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 a0054f741ea..bb28881c70e 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 130319cea73..eb878075f57 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 1df4f0e293f..6616b1cb524 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 4fd5097dc9f..a17f2f37fe1 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 97524254bf7..c1aa38a00a2 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 40e24385c53..f4076b1bfe3 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 93cd3d80bcc..5edc6ad50b4 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 cdbf6efc497..74f4ecc1f59 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 62c4a6f6941..20c3e7da079 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 31ef0160426..5ec40e40d71 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 3e29f16b8ba..9955e753931 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 7f387f7d6b3..5b8b386ba15 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 913324c4a55..561b0400728 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 a3eb4489a51..52189578f72 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 87128304e00..e1d20ac132c 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 9057f517554..e3d59167096 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 8f04301fc9e..d512d10bcc4 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 a0554d6ea1a..af2c925d5c2 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 948ec31161a..95d59c2b8ef 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 8818f683913..bfcca574074 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 614cd6a3ff2..30818b6c341 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 a07ee6be9b5..76b999d9299 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 33fc91473c3..3099b76eb88 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 c8a6fc7933e..0f7d2a61d3b 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 3767f02905d..88e66e8f2d2 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 05c6f8e16e5..38e06323398 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 a27e4356afb..2e63b6ec437 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 e6b67d7889d..03f72b41a4f 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 dba09b2c706..49738d83664 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 d480d4a30d57e02bc5b25f1ab35754930e7b4b6c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 12 Oct 2020 10:00:10 +0200 Subject: [PATCH 387/710] 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 e69de29bb2d..7f274ec28fa 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 19a6aca25ee31aa352bf9fc3b51e554a1151f74c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 13 Oct 2020 10:07:25 +0200 Subject: [PATCH 388/710] shopfloor: fix linting From 52c07503d5f9c93e9c1730f884de0dd10f1a4012 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 13 Oct 2020 16:50:24 +0200 Subject: [PATCH 389/710] 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 4adf2c25123..12590ac995a 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 899cf0321bb..6a90cb2e79d 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 059a7dda317..92102a8b422 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 2d5767567e5..d628404b12c 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 00000000000..62e8f7eadeb --- /dev/null +++ b/shopfloor/static/description/index.html @@ -0,0 +1,530 @@ + + + + + + +Shopfloor + + + +
+

Shopfloor

+ + +

Alpha License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runbot

+

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

+ +
+

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.

+
+ +
+

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

+ +
+
+

Design

+ +
+
+

Other credits

+

Financial support

+
    +
  • Cosanum
  • +
  • Camptocamp R&D
  • +
  • Akretion R&D
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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:

+

guewen simahawk 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.

+
+
+
+ + From 504498b1ffef0065bf9cc437f56c63dac5373eea Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 13 Oct 2020 17:46:40 +0200 Subject: [PATCH 390/710] 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 88bddd0ba2d..df5aafd7d29 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 a301d9ac426b558f92fad14b6afafc10a0d3beba Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 19 Oct 2020 10:52:49 +0200 Subject: [PATCH 391/710] 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 df5aafd7d29..0eb6612b5b3 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 9f1dff886eafb5e736f6721efeb5a906536d97fb Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 19 Oct 2020 14:57:52 +0200 Subject: [PATCH 392/710] 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 e2ef5937194..8c49038db3f 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 eb4352fda9cab84a5eb2fe54f0fdf9e1b936ffce Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 20 Oct 2020 10:33:32 +0200 Subject: [PATCH 393/710] 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 000aeb6b99f..03dc024de0d 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 f1f070bd0c340787375d39ece7b9e2c11591522b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 20 Oct 2020 10:56:20 +0200 Subject: [PATCH 394/710] 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 6616b1cb524..dcd379f6f7e 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 9a1a4c08b9b43ee55d01bd6d7b2375ab72e71b50 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 20 Oct 2020 12:24:02 +0200 Subject: [PATCH 395/710] 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 03dc024de0d..49326a0f2b5 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 dcd379f6f7e..43ee6ad58cd 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 b0ffc228adbfe9bc1f5acc656000520d417787ae Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 20 Oct 2020 13:04:20 +0200 Subject: [PATCH 396/710] 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 ed3a1b77e74..33c3da1d3e2 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 43ee6ad58cd..5d9ed6f0bb5 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 84a6ca8155c6a8caea5869558687bc3317ddb5f3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 19 Oct 2020 14:55:07 +0200 Subject: [PATCH 397/710] 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 2794cf4449f..6c8f2abb4d5 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 8201eaec441..ae095967102 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 2e63b6ec437..0e856173455 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 c3eaefa9e978c9a2c1831c03d8cd4fc7fa04ac21 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 14 Oct 2020 11:09:32 +0200 Subject: [PATCH 398/710] 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 9a0401ceddc..9e04a0f1ce2 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 c0242b89a45..adb6facf63c 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 00000000000..f6a4a8ed48b --- /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 6ebb576f7cb..7f250d96a35 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 399/710] 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 7f250d96a35..5e4ed99f53c 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 400/710] 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 adb6facf63c..dcbc74e0f17 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 f6a4a8ed48b..67953272920 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 8670cd6230272c08c35d71604eae12c515a49c90 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 14 Oct 2020 13:49:18 +0200 Subject: [PATCH 401/710] 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 5e4ed99f53c..b2dee1d9f93 100644 --- a/shopfloor/views/shopfloor_log_views.xml +++ b/shopfloor/views/shopfloor_log_views.xml @@ -36,12 +36,8 @@ - - - - - - + + - + + From f95ab2a9be83139a2984cf665b326cf1690ab2b1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 14 Oct 2020 13:50:22 +0200 Subject: [PATCH 402/710] 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 6a90cb2e79d..f7d6b4a1233 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 00000000000..be81c55e98a --- /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 9e04a0f1ce2..62bb26519be 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 dcbc74e0f17..6070c0c190a 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 17f22a438dce48f3295364c9c5297192344ec6ba Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 19 Oct 2020 10:08:43 +0200 Subject: [PATCH 403/710] 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 b2dee1d9f93..603a185c3b7 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 404/710] 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 20c3e7da079..29d0470d163 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 f846ddb6b0b05e9d068050283a9a038a0d5ad47f 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 405/710] 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 4e296d82a81..3ed93c45909 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 dff3a7b1d15ec46a5d01d164f3c3029092bf6637 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 29 Sep 2020 11:20:10 +0200 Subject: [PATCH 406/710] 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 ae095967102..e61992cf2b6 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 95d59c2b8ef..e6fdd4ec0c9 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 0f7d2a61d3b..2e1ecdc2425 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 cc13b56454cfc5fdb1236525d66bb6faaee00b4d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 08:17:11 +0100 Subject: [PATCH 407/710] 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 74cf09bce6e..476d97073a2 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 d0d826129598fe81e59b4d9bcebc1fba2fb96ead Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 10:32:13 +0100 Subject: [PATCH 408/710] 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 3ed93c45909..35f8395fb4a 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 b3e8dec066eadd31bf060f52c4a4d1667dd1c7c5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 26 Oct 2020 09:10:43 +0100 Subject: [PATCH 409/710] 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 86676e235d2..218e08e3b74 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 6070c0c190a..dca6d9c00d7 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) @@ -376,6 +378,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 0eb6612b5b3..e3c7a056f66 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 043e097435a42c76096c85aea628a0c04a767a4b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 26 Oct 2020 09:14:33 +0100 Subject: [PATCH 410/710] 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 12590ac995a..db94b7f8d3e 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 d628404b12c..9f4832dd09b 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 166173654b6..b3d8962f256 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 d0d2be4704d..26edc2cfb09 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 00000000000..634264dddbb --- /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 62e8f7eadeb..cc20fd6eccf 100644 --- a/shopfloor/static/description/index.html +++ b/shopfloor/static/description/index.html @@ -450,7 +450,7 @@

Usage

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 575ef7fba12..3a8e6d7fdb3 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 11088781ade..f9b4f514bef 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 00000000000..066a3f2b3be --- /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 5d0c5d878ed85f3065c02a6f43de1327268a050c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 23 Oct 2020 10:03:58 +0200 Subject: [PATCH 411/710] 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 b3d8962f256..7fe2f664ea1 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 00000000000..8d328d097a7 --- /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 00000000000..1389082c80d --- /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 00000000000..dffebbf9ad2 --- /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 3a8e6d7fdb3..9df64495000 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 00000000000..0ab9cc3e8c7 --- /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 f42c7f95ac7d86c712872248d6cdd8779ac95784 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 27 Oct 2020 11:00:33 +0100 Subject: [PATCH 412/710] 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 2bef510cbfe..e3024da6042 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 f57d40e33f4..934c27b3d44 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 1595c0af855..c20b2f1dc0b 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 e66e6bcb93c49c67791b60bcd981ad6fe374ee09 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 27 Oct 2020 11:01:01 +0100 Subject: [PATCH 413/710] 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 bb4f74f099f..b66146f7aeb 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 c20b2f1dc0b..a87ad57fc94 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 1767c42836b7396841d586221295a8c8b7bbb5cf Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 13:51:28 +0100 Subject: [PATCH 414/710] 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 be81c55e98a..e7f946fbcb8 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 a93485c363a3f5f90a5e878e9ce18e4440344e95 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 15:25:01 +0100 Subject: [PATCH 415/710] 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 e600f00c4ea..db9850e8ada 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 9429a8c142d..c2a351282ce 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 7777787659c..15357990138 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 3d2502da9d9228541bfe79ae538f0af2ce432734 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 15:26:59 +0100 Subject: [PATCH 416/710] 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 e264be477aa..4bddbd3bcce 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 9df64495000..415f01e90cf 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 e3d59167096..764e62f1271 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 00000000000..aeb953a498a --- /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 f8912e3ed3c9dce18ea95ab1152846de54910bc4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 26 Oct 2020 16:17:45 +0100 Subject: [PATCH 417/710] 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 35f8395fb4a..66c30026ba5 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 00000000000..5b82a61f41a --- /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 29d0470d163..c193fd03167 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 c7a0d9a476f2bbbc18a06b948a354cbbbd36f9ab Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 27 Oct 2020 08:26:08 +0100 Subject: [PATCH 418/710] 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 15357990138..552c2e71937 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 5416e0d04b397f7fd8aaac9e2ac7b6e7ecdc40eb Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 27 Oct 2020 11:32:47 +0100 Subject: [PATCH 419/710] 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 f7d6b4a1233..0adc6d0d30a 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 c6de852aac32b1b272e748527d7db57da215a699 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 30 Oct 2020 12:02:52 +0100 Subject: [PATCH 420/710] 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 66c30026ba5..c2a8dbe4c2e 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 74f4ecc1f59..1a4c2daecac 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 950d3bf0d1d47a03bd5035b3a352ad51abbdef92 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 421/710] 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 bd8b2c3de3a..f6105e4d555 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 26c17ee886bbe539e20e629c1ed065164e89fdc5 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Tue, 3 Nov 2020 13:45:02 +0000 Subject: [PATCH 422/710] [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 00000000000..0ffe4b6c66f --- /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 d5834b09d6cb18faf22b240e812b71163fa84676 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 3 Nov 2020 14:23:23 +0000 Subject: [PATCH 423/710] [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 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 From d98dae8fce5a76cd45b2e58b02c5a086dbb99ec0 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 424/710] 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 e6fdd4ec0c9..2d4eb6904e7 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 2e1ecdc2425..2019e9969b6 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 38e06323398..23ec7800e89 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 0e856173455..61084543864 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 0a05f78e44912bceaf377adca919e0c37980d4cf 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 425/710] 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 6c8f2abb4d5..20473b6f267 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 aa9b0ef77e5953b93f2ed0871e0b00bd4306f4d2 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 426/710] [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 e61992cf2b6..b7fa75166b6 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 e8c6a7202d5042dcf463dd77902d371c7c383d15 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 10 Nov 2020 08:43:18 +0000 Subject: [PATCH 427/710] 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 0adc6d0d30a..0e5181d14e3 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 236847415356b0f5f32dce9ec75f302fde1f7d27 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 10 Nov 2020 09:25:45 +0000 Subject: [PATCH 428/710] 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 0e5181d14e3..2e74a0ffc1b 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 e9c9eceeca86e90074ad44e8c575d88248516caa 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 429/710] [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 db9850e8ada..90cdf2f174f 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 b7fa75166b6..e7183208b45 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 2d4eb6904e7..2f7661ba241 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 30818b6c341..20851d25e02 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 ef65a3d4a7dffc9b202c5ed1b6f8b181e3efa295 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 12 Nov 2020 12:57:32 +0000 Subject: [PATCH 430/710] [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 0ffe4b6c66f..1a4809fa372 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 e0737b6cec3bc70b4c34fdb60a2699277e9b1301 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 12 Nov 2020 13:22:38 +0000 Subject: [PATCH 431/710] 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 2e74a0ffc1b..d71334a4a39 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 a019377cd5a9e2f802f0b4a018df0bbc3299fde9 Mon Sep 17 00:00:00 2001 From: hparfr Date: Tue, 13 Oct 2020 12:28:20 +0200 Subject: [PATCH 432/710] 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 dca6d9c00d7..dff74895f28 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -289,9 +289,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 46b4021bfc8c48990e3b5bff38182308f8442bca Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 13 Nov 2020 11:31:29 +0000 Subject: [PATCH 433/710] 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 d71334a4a39..8c428c7dad1 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 162e9de4c2b3d5b6b044baeaef4b0446dc33ec6b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 13 Nov 2020 16:42:23 +0100 Subject: [PATCH 434/710] 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 e7183208b45..3475518b580 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 a31584892ae87ff64c7da6b0f18ebfe9789756b1 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 17 Nov 2020 14:04:50 +0000 Subject: [PATCH 435/710] 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 8c428c7dad1..32de4302451 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 c41e1f8e2c70c5aca2c8dd3d5009b2583ef367d9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 17 Nov 2020 17:06:56 +0100 Subject: [PATCH 436/710] 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 3475518b580..ff0cece34b2 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 20851d25e02..bdc44beb5a3 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 f32e5918ba885467105f2cd64e9aaa3689ed05de Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 17 Nov 2020 17:58:42 +0100 Subject: [PATCH 437/710] 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 33c3da1d3e2..9e91d91df04 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 48c6d9f60e6..e1d7dc983de 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 a8dc8ad673b77d4938e54ebc04eea714d0455ff1 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 19 Nov 2020 11:04:08 +0000 Subject: [PATCH 438/710] 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 32de4302451..395cffed921 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 982c84b70305f383c86a2ed817fcab84cb3100bb Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 19 Nov 2020 11:22:52 +0100 Subject: [PATCH 439/710] 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 e3c7a056f66..d298be103ed 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 bdc44beb5a3..7c5eadeddd4 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 99d791805cca6072060216a186a63c6097e8da94 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 19 Nov 2020 11:26:36 +0100 Subject: [PATCH 440/710] 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 ff0cece34b2..5ee98f00a22 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 2b2829839cd2726c04da97c2f1f6db7afc720b4e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Sat, 21 Nov 2020 11:12:50 +0100 Subject: [PATCH 441/710] 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 395cffed921..79026c348b8 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 46f4844426620b0151f70b1f131aae5feaf3f6b5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 25 Nov 2020 10:43:03 +0100 Subject: [PATCH 442/710] 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 79026c348b8..4f6dfa30d2d 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 c2a351282ce..e398fe32cf4 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 dff74895f28..958fe5dc898 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -438,6 +438,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 7bdcace056b078a501d6f2b4b94cded5af43eaf5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 25 Nov 2020 10:44:24 +0100 Subject: [PATCH 443/710] 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 4bf0b095b66..2989e6d4e07 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 72420778258..e60a67ecfc2 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 477fed1f3f5..2218060026e 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 151beb76e2b..f361b26fd89 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 ee8e60528098ac8310b2065f679ac4f0f18f8174 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 20 Nov 2020 14:22:39 +0100 Subject: [PATCH 444/710] 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 5ee98f00a22..979232c8b36 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 117c3139b2f70ecb839da932bb27e229b50fbdf0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 20 Nov 2020 14:25:31 +0100 Subject: [PATCH 445/710] 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 2019e9969b6..6f20c4eb957 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 66359a1474aea2fb023af2eca8ece80c8b8ec33b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 10:33:23 +0100 Subject: [PATCH 446/710] 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 464f279228e..788ec5e8a9d 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 00000000000..21d11c23bcf --- /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 958fe5dc898..d390850e2f4 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -344,17 +344,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) @@ -379,6 +380,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): @@ -466,8 +472,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? @@ -475,13 +485,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 6851a09f22cd68eff155e94b5432e2a4a92d6bec Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 10:34:06 +0100 Subject: [PATCH 447/710] 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 979232c8b36..010fd3c49b2 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 10c50db3332de1cf1c4955b44f55bcda745e3a25 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 13:08:22 +0100 Subject: [PATCH 448/710] 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 d390850e2f4..467b75c420f 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -344,7 +344,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 @@ -357,7 +357,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) @@ -472,11 +474,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 @@ -489,6 +486,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 ffc0b4867f8e9a9e2d856d65d90ace00c9a4dba0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 13:09:11 +0100 Subject: [PATCH 449/710] 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 bb9071e9d77..c86e11360ca 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 010fd3c49b2..2ef04204a65 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 dd4f0bdae788c3e024945762100cbd7039649cac Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 16:00:11 +0100 Subject: [PATCH 450/710] 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 3fce4f2182f..cb82e535176 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 415f01e90cf..c15f4126580 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 5ec40e40d71..c27b937bdef 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 00000000000..a85d4076779 --- /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 00000000000..8cec852e086 --- /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 0d9f0749d9784acdcc0b523283e973dacae4eaae Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 23 Nov 2020 16:01:34 +0100 Subject: [PATCH 451/710] 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 922df080400..4592653ab8b 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 a53158e42236641d8567623e459ce73744f60c4a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 26 Nov 2020 15:04:22 +0100 Subject: [PATCH 452/710] 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 62bb26519be..110f04cf089 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 c15f4126580..3146498a5af 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 67953272920..7a982b9169a 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 415ccd88c6e8430f958c95e96293b00087a842fd Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 26 Nov 2020 15:04:47 +0100 Subject: [PATCH 453/710] 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 066a3f2b3be..e1175ae8bbf 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 b505b13a74791a4dfe6643b84b331023dedfefa9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 26 Nov 2020 15:38:05 +0100 Subject: [PATCH 454/710] 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 467b75c420f..11f1b6ef138 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -496,6 +496,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 2f25c0423efd2e353d6f238e75010599ef3a02c5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 24 Nov 2020 09:41:35 +0100 Subject: [PATCH 455/710] 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 4f6dfa30d2d..fe42dc3e39e 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 0607611bbe33de9426ff6c9475e255b8aef5beb2 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 1 Dec 2020 16:16:08 +0000 Subject: [PATCH 456/710] 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 fe42dc3e39e..4ed966e3a52 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 bb79fae7aefac7d67d70f693cb9c2e085201e6b6 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 1 Dec 2020 10:56:13 +0100 Subject: [PATCH 457/710] 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 c21e8e92e8a..82876f1d04b 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 90cdf2f174f..a5e6c924af7 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 967729f0445..03678c0b565 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 240bdba68149a6499fe5d2bfb89c22d9d51b2a29 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 1 Dec 2020 12:04:05 +0100 Subject: [PATCH 458/710] 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 3146498a5af..6013d938859 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 00000000000..470cf8cba04 --- /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 49326a0f2b5..00000000000 --- 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 00000000000..4b7ab393055 --- /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 00000000000..67a6e33e67c --- /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 aade4e73b6078f1957d82e1b9dff20290817c3f9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 1 Dec 2020 12:13:54 +0100 Subject: [PATCH 459/710] 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 9e91d91df04..620751c6d5f 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 67a6e33e67c..626779a7f9e 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 f35534df00f8fb927fb8f35d9704348e2bd3e281 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 1 Dec 2020 14:49:19 +0100 Subject: [PATCH 460/710] 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 21d11c23bcf..2b5adbba5c9 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 8b4e5266988e2bfacaf922f274a93376a7da99a0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 3 Dec 2020 12:53:22 +0100 Subject: [PATCH 461/710] 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 4ed966e3a52..9735277ec7d 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 904318a888b1fe4e86b2a47b213bcb869884f2b6 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 462/710] [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 5301f902456..f1d3bbb6440 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 e3024da6042..ddd7527802f 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 110f04cf089..9e94415f013 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 b66146f7aeb..6b8c3cd921e 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 35cb047cc9f..e37e3ff67de 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 2525db4aaa7..984a69cb8c0 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 d20af1278e9..bc17dcd29b4 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 8f7ee23462d..c155115bcaf 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 2989e6d4e07..178ce153010 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 620751c6d5f..e5db7fd0070 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 476d97073a2..f8aea169ea0 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 1389082c80d..f0d1d4aaa79 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 cb82e535176..02a6a90edeb 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 4bddbd3bcce..b0c7a6faa29 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 a18adba265b..6a9d4e74a7e 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 e60a67ecfc2..07cb846c34a 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 e1d7dc983de..02f35414afd 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 bb28881c70e..03feec18fd3 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 eb878075f57..ce486d3ee61 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 5d9ed6f0bb5..b32dcf1938a 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 8c49038db3f..1162aa1eb85 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 056557d4071..ec82a350a53 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 c1aa38a00a2..ae2cd598f67 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 f4076b1bfe3..cbc1c2c852e 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 2b440f5202f..b7f6753cb5f 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 5b82a61f41a..6421f71babe 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 6212307b49a..1853edba35f 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 1a4c2daecac..ae416df8866 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 c193fd03167..ebdfb1c7bf1 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 e1d20ac132c..3e49d472ed8 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 aeb953a498a..586fb145a93 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 e1175ae8bbf..fa873cb42c8 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 2f7661ba241..69dbc125ba3 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 bfcca574074..51985e041a7 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 7c5eadeddd4..35a0ab8e736 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 76b999d9299..7638292fa2f 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 3099b76eb88..a4c1594b92b 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 6f20c4eb957..6bda88beaa7 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 88e66e8f2d2..d755113e700 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 23ec7800e89..33997cf93d3 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 61084543864..5e6b9350328 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 03f72b41a4f..1db582c00ca 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 49738d83664..48ad12ab064 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 79b06910e94ef28a7467526c61c2957dd03e92e0 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 10 Dec 2020 19:14:44 +0000 Subject: [PATCH 463/710] [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 db94b7f8d3e..b8a2a4657d7 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 cc20fd6eccf..fc9823dfd8e 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. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Alpha License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runbot

+

Alpha License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runbot

Shopfloor is a barcode scanner application for internal warehouse operations.

The application supports scenarios, to relate to Operation Types:

    @@ -472,7 +472,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.

@@ -521,7 +521,7 @@

Maintainers

promote its widespread use.

Current maintainers:

guewen simahawk 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.

From dd256d3356912d20c29779dad79d41305f731c4a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 14 Dec 2020 16:19:22 +0100 Subject: [PATCH 464/710] 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 9735277ec7d..a519a8cbc90 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 6ca73ae0a591e73d4e871dac71dbfd6028dbff84 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 20 Jan 2021 08:51:13 +0100 Subject: [PATCH 465/710] 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 11f1b6ef138..3cb1c875dc0 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 7a982b9169a..737feb3861b 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 0ab9cc3e8c7..8ae8ba677e1 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 e9d2a13b10bfcb1b14e170f3ed44959acbe857e0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 20 Jan 2021 08:56:28 +0100 Subject: [PATCH 466/710] 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 d298be103ed..2a445122eb3 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 94d101f051ba6fad663508ec47bbb3f5b13f1ff4 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 20 Jan 2021 08:57:45 +0100 Subject: [PATCH 467/710] 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 3cb1c875dc0..4108ba057d6 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 aac6ef2e56dffdc134cb8e488dfe7046210736ab 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 468/710] 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 788ec5e8a9d..43f307a745a 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 00000000000..54e9f0b8487 --- /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 20473b6f267..26f34fbc2bf 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 c2a8dbe4c2e..4fafb5e0c21 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 b0c7a6faa29..917d47e8a7e 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 2ef04204a65..cb01aa31680 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 af2c925d5c2..d4f099045fc 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 33997cf93d3..8880b4dcdaf 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 5e6b9350328..f0c475cb1e4 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 a09c132a89f1483cc57279096132df1c7c93eace Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Sat, 6 Feb 2021 18:33:27 +0000 Subject: [PATCH 469/710] 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 00000000000..cfb0be0099b --- /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 dbef6867b3dcbd57042c02a74cc7a6811eef1d95 Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Sat, 6 Feb 2021 20:06:38 +0000 Subject: [PATCH 470/710] 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 cfb0be0099b..ac41377758e 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 e0eeaaefcc193d852d4b6081ca0cc53031774cf0 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 471/710] [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 e5db7fd0070..1697e43ec70 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 b32dcf1938a..d1b3de47e1c 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 a2e12665d9fdb2304bf94efb78d0e94010f11616 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 13 Jan 2021 09:10:50 +0000 Subject: [PATCH 472/710] 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 a519a8cbc90..9ca78bd7366 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 f0e29fd106b021363c04f1b7419740201eaf1482 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 473/710] [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 178ce153010..6605c3bc6a5 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 1af8473da20..6cc8c5708c1 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 3c962746fd20af9e18ab6585ed70d50437f23ec4 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 474/710] [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冭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 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 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 bQrM??uO)K1QeP!)LvEO0DlI_l&&>V@Q-sco literal 0 HcmV?d00001 diff --git a/shopfloor/docs/zone_picking_diag_seq.txt b/shopfloor/docs/zone_picking_diag_seq.txt new file mode 100644 index 00000000000..1884644dcd4 --- /dev/null +++ b/shopfloor/docs/zone_picking_diag_seq.txt @@ -0,0 +1,84 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml zone_picking_diag_seq.txt +# + +@startuml + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Zone picking scenario + +== /select_zone == +[-> start: **/select_zone** + +== /scan_location == +start -> select_picking_type: **/scan_location** + +== /list_move_lines == +select_picking_type -> select_line: **/list_move_lines** + +== /scan_source == +select_line -> set_line_destination: **/scan_source** + +== /set_destination == +set_line_destination -[#red]> set_line_destination: **/set_destination** \n(error) +set_line_destination -> set_line_destination: **/set_destination** \n(scanned location not expected but valid, confirmation required) +set_line_destination -> select_line: **/set_destination** +set_line_destination -> zero_check: **/set_destination** +zero_check -> select_line: **/is_zero** + +== /prepare_unload == +select_line -> unload_single: **/prepare_unload** \n(different destinations in buffer lines, process one by one) +select_line -> unload_set_destination: **/prepare_unload** \n(only one move line in the buffer) +select_line -> unload_all: **/prepare_unload** \n(buffer lines have all the same destination location) +select_line -> select_line: **/prepare_unload** \n(no remaining lines in the buffer) + + +== /set_destination_all == +unload_all -[#red]> unload_all: **/set_destination_all** \n(error, scanned destination location invalid) +unload_all -> unload_all: **/set_destination_all** \n(scanned destination not expected but valid, confirmation required) +unload_all -> select_line: **/set_destination_all** \n(move lines still need to be processed) +unload_all -> start: **/set_destination_all** \n(all move lines have been processed) + +== /unload_split == +select_line -> unload_single: **/unload_split** +select_line -> unload_set_destination: **/unload_split** +select_line -> select_line: **/unload_split** + +== /unload_scan_pack == +unload_single -[#red]> unload_single: **/unload_scan_pack** \n(error) +unload_single -> unload_set_destination: **/unload_scan_pack** +unload_single -> select_line: **/unload_scan_pack** +unload_single -> start: **/unload_scan_pack** + +== /unload_set_destination == +unload_set_destination -> unload_single: **/unload_set_destination** +unload_set_destination -[#red]> unload_set_destination: **/unload_set_destination** \n(error) +unload_set_destination -> select_line: **/unload_set_destination** +unload_set_destination -> start: **/unload_set_destination** + +== /stock_issue == +set_line_destination -> stock_issue: Button **"stock out"** (client-side) +stock_issue -> set_line_destination: **/stock_issue** \n(goods are available after the inventory) +stock_issue -> select_line: **/stock_issue** \n(goods still not available) + +== /change_pack_lot == +set_line_destination -> change_pack_lot: Button "Change pack or lot" (client-side) +change_pack_lot -> set_line_destination: **/change_pack_lot** \n(pack/lot has been changed on the line) +change_pack_lot -[#red]> change_pack_lot: **/change_pack_lot** \n(error, unable to change pack/lot on the line) + +@enduml diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index cb01aa31680..74d6eb98780 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -18,6 +18,9 @@ class ZonePicking(Component): Zone picking of move lines. + You will find a sequence diagram describing states and endpoints + relationships [here](../docs/zone_picking_diag_seq.png). + Note: * Several operation types could be linked to a single menu item @@ -713,8 +716,8 @@ def set_destination( 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) + * set_line_destination+confirmation_required: the scanned location is not + in the expected one but is valid (in picking type's default destination) """ zone_location = self.env["stock.location"].browse(zone_location_id) if not zone_location.exists(): From e9dba7b4ab8858c8e6a7139a39c5536d2fd43402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 10 Nov 2020 13:29:11 +0100 Subject: [PATCH 475/710] [DOC] shopfloor: delivery sequence diagram --- shopfloor/docs/delivery_diag_seq.png | Bin 0 -> 73563 bytes shopfloor/docs/delivery_diag_seq.txt | 56 +++++++++++++++++++++++++++ shopfloor/services/delivery.py | 3 ++ 3 files changed, 59 insertions(+) create mode 100644 shopfloor/docs/delivery_diag_seq.png create mode 100644 shopfloor/docs/delivery_diag_seq.txt diff --git a/shopfloor/docs/delivery_diag_seq.png b/shopfloor/docs/delivery_diag_seq.png new file mode 100644 index 0000000000000000000000000000000000000000..44647b6b853a507406fc14decbfd1233a8553c16 GIT binary patch literal 73563 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 literal 0 HcmV?d00001 diff --git a/shopfloor/docs/delivery_diag_seq.txt b/shopfloor/docs/delivery_diag_seq.txt new file mode 100644 index 00000000000..0747eb01d2a --- /dev/null +++ b/shopfloor/docs/delivery_diag_seq.txt @@ -0,0 +1,56 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml delivery_diag_seq.txt +# + +@startuml + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Delivery scenario + +== /list_stock_picking == +deliver -> manual_selection: **/list_stock_picking** + +== /select == +manual_selection -> deliver: **/select**(picking_id) + +== /scan_deliver == +deliver -> deliver: **/scan_deliver**(barcode, picking_id=None) \n(scan a picking to display its lines, or a package/lot/product to process related lines,\nwhen all the available move lines of the transfer are done, the stock picking is set to done) + +== /set_qty_done_pack == + +deliver -> deliver: **/set_qty_done_pack(picking_id, package_id)** \n(when all the available move lines of the transfer are done, the transfer is set to done.) + +== /set_qty_done_line == + +deliver -> deliver: **/set_qty_done_line(picking_id, move_line_id)** \n(when all the available move lines of the transfer are done, the transfer is set to done.) + +== /reset_qty_done_pack == + +deliver -> deliver: **/reset_qty_done_pack(picking_id, package_id)** \n(remove "Done" on a package) + +== /reset_qty_done_line == + +deliver -> deliver: **/reset_qty_done_line(picking_id, move_line_id)** \n(remove "Done" on a move line) + +== /done == + +deliver -> deliver: **/done(picking_id)** \n(all lines processed) +deliver -> confirm_done: **/done(picking_id)** \n(lines partially processed, need confirmation) +confirm_done -> deliver: **/done(picking_id, confirm=True)** + +@enduml diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index f8aea169ea0..8390a963cc5 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -17,6 +17,9 @@ class Delivery(Component): Multiple operators could be processing a same delivery order. + You will find a sequence diagram describing states and endpoints + relationships [here](../docs/delivery_diag_seq.png). + Expected: * Existing packages are moved to customer location From 2042f0d5f8f855bad7e05e800ccb0287202d1090 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 13 Jan 2021 11:34:51 +0100 Subject: [PATCH 476/710] shopfloor: expose user info --- shopfloor/services/app.py | 10 +++++++++- shopfloor/services/user.py | 32 ++++++++++++++++++++++++++++++++ shopfloor/tests/test_app.py | 1 + shopfloor/tests/test_user.py | 8 ++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py index 26edc2cfb09..ecbc8f21c08 100644 --- a/shopfloor/services/app.py +++ b/shopfloor/services/app.py @@ -16,7 +16,9 @@ class ShopfloorApp(Component): def user_config(self): profiles_comp = self.component("profile") profiles = profiles_comp._to_json(profiles_comp._search()) - return self._response(data={"profiles": profiles}) + user_comp = self.component("user") + user_info = user_comp._user_info() + return self._response(data={"profiles": profiles, "user_info": user_info}) class ShopfloorAppValidator(Component): @@ -39,6 +41,7 @@ class ShopfloorAppValidatorResponse(Component): def user_config(self): profile_return_validator = self.component("profile.validator.response") + user_return_validator = self.component("user.validator.response") return self._response_schema( { "profiles": { @@ -49,5 +52,10 @@ def user_config(self): "schema": profile_return_validator._record_schema, }, }, + "user_info": { + "type": "dict", + "required": True, + "schema": user_return_validator._user_info_schema(), + }, } ) diff --git a/shopfloor/services/user.py b/shopfloor/services/user.py index 634264dddbb..ceed40c9ea6 100644 --- a/shopfloor/services/user.py +++ b/shopfloor/services/user.py @@ -1,5 +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.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -17,6 +18,17 @@ def menu(self): menus = menu_comp._to_json(menu_comp._search()) return self._response(data={"menus": menus}) + # TODO: this endpoint does not require profile header + def user_info(self): + return self._response(data={"user_info": self._user_info()}) + + def _user_info(self): + return self.env.user.jsonify(self._user_info_parser, one=True) + + @property + def _user_info_parser(self): + return ["id", "name"] + class ShopfloorUserValidator(Component): """Validators for the User endpoints""" @@ -28,6 +40,9 @@ class ShopfloorUserValidator(Component): def menu(self): return {} + def user_info(self): + return {} + class ShopfloorUserValidatorResponse(Component): """Validators for the User endpoints responses""" @@ -50,3 +65,20 @@ def menu(self): }, } ) + + def user_info(self): + return self._response_schema( + { + "user_info": { + "type": "dict", + "required": True, + "schema": self._user_info_schema(), + } + } + ) + + def _user_info_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 f9b4f514bef..c85d6e9cd16 100644 --- a/shopfloor/tests/test_app.py +++ b/shopfloor/tests/test_app.py @@ -34,5 +34,6 @@ def test_user_config(self): } for profile in profiles ], + "user_info": {"id": self.env.user.id, "name": self.env.user.name}, }, ) diff --git a/shopfloor/tests/test_user.py b/shopfloor/tests/test_user.py index fa873cb42c8..384d8888933 100644 --- a/shopfloor/tests/test_user.py +++ b/shopfloor/tests/test_user.py @@ -38,3 +38,11 @@ def test_menu_by_profile(self): response, data={"menus": [self._data_for_menu_item(menu)]}, ) + + def test_user_info(self): + """Request /user/user_info""" + response = self.service.dispatch("user_info") + self.assert_response( + response, + data={"user_info": {"id": self.env.user.id, "name": self.env.user.name}}, + ) From 998167a461c9a3ca7c5608ba5f0f5d4f53cdfe7b Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 13 Jan 2021 11:16:15 +0000 Subject: [PATCH 477/710] shopfloor 13.0.1.5.3 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 9ca78bd7366..b098d2d03b0 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.2", + "version": "13.0.1.5.3", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 477a39a36294ffbae44004b5bcd0075bd13f1117 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 10 Dec 2020 16:23:41 +0100 Subject: [PATCH 478/710] shopfloor: zone_picking.scan_source fix scan location When a location is scanned and several items are found ask to scan a package instead of saying that the location is empty. --- shopfloor/services/zone_picking.py | 39 ++++++++++++++++++------------ 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 74d6eb98780..2b2376a59d8 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -385,32 +385,26 @@ 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, order="priority" - ): + def _scan_source_location(self, zone_location, picking_type, location, **kw): """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 move_lines = self._find_location_move_lines( - location, - picking_type=picking_type, - product=product, - package=package, - lot=lot, - match_user=True, + location, picking_type=picking_type, match_user=True, **kw ) if move_lines: return first(move_lines) return False + def _find_product_in_location(self, location): + quants = self.env["stock.quant"].search([("location_id", "=", location.id)]) + product = quants.product_id + lot = quants.lot_id + package = quants.package_id + return product, lot, package + 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 @@ -464,8 +458,21 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit return self._response_for_start( message=self.msg_store.location_not_allowed() ) + product, lot, package = self._find_product_in_location(location) + if len(product) > 1 or len(lot) > 1 or len(package) > 1: + response = self.list_move_lines(location.id, picking_type.id) + return self._response( + base_response=response, + message=self.msg_store.several_products_in_location(location), + ) move_line = self._scan_source_location( - zone_location, picking_type, location, order=order + zone_location, + picking_type, + location, + order=order, + product=product, + lot=lot, + package=package, ) # if no move line, narrow the list of move lines on the scanned location if not move_line: From 316daec298a52a039b6b3b8801150a81d263556e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 10 Dec 2020 16:27:08 +0100 Subject: [PATCH 479/710] shopfloor: message fix multiple calls to gettext --- shopfloor/actions/message.py | 14 +++++--------- shopfloor/tests/test_zone_picking_select_line.py | 4 +++- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index a5e6c924af7..38abf195041 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -283,10 +283,8 @@ 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." - ) + "This product is part of a package with other products, " + "please scan a package." ), } @@ -306,10 +304,8 @@ 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." - ) + "This lot is part of a package with other products, " + "please scan a package." ), } @@ -436,7 +432,7 @@ def package_already_picked_by(self, package, picking): return { "message_type": "error", "body": _( - _("Package {} cannot be picked, already moved by transfer {}.") + "Package {} cannot be picked, already moved by transfer {}." ).format(package.name, picking.name), } diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index 35a0ab8e736..afb51be0934 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -249,7 +249,9 @@ 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.location_empty(self.zone_sublocation2), + message=self.service.msg_store.several_products_in_location( + self.zone_sublocation2 + ), ) def test_scan_source_barcode_package(self): From e9b62151ea6053b107026d1d8010d7768b80ebdd Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 10 Dec 2020 18:19:22 +0100 Subject: [PATCH 480/710] shopfloor: refactor zone_picking.scan_source --- shopfloor/services/zone_picking.py | 205 +++++++++++++++++------------ 1 file changed, 122 insertions(+), 83 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 2b2376a59d8..99b244e8dd7 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -385,43 +385,130 @@ 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, **kw): - """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`. + def _scan_source_location( + self, zone_location, picking_type, barcode, order="priority" + ): + """Search a location and find available lines into it. """ + response = None + message = None + search = self.actions_for("search") + location = search.location_from_scan(barcode) + if not location: + return response, message + + if not location.is_sublocation_of(zone_location): + response = self._response_for_start( + message=self.msg_store.location_not_allowed() + ) + return response, message + + product, lot, package = self._find_product_in_location(location) + if len(product) > 1 or len(lot) > 1 or len(package) > 1: + response = self.list_move_lines(location.id, picking_type.id, order=order) + message = self.msg_store.several_products_in_location(location) + return response, message + move_lines = self._find_location_move_lines( - location, picking_type=picking_type, match_user=True, **kw + location, + picking_type=picking_type, + product=product, + lot=lot, + package=package, + order=order, + match_user=True, ) if move_lines: - return first(move_lines) - return False + response = self._response_for_set_line_destination( + zone_location, picking_type, first(move_lines) + ) + else: + # if no move line, narrow the list of move lines on the scanned location + response = self.list_move_lines(location.id, picking_type.id, order=order) + message = self.msg_store.location_empty(location) + return response, message def _find_product_in_location(self, location): + """Find a prooduct in stock in given location move line in the location. + """ quants = self.env["stock.quant"].search([("location_id", "=", location.id)]) product = quants.product_id lot = quants.lot_id package = quants.package_id return product, lot, package - def _scan_source_package(self, zone_location, picking_type, package, order): + def _scan_source_package( + self, zone_location, picking_type, barcode, order="priority" + ): + """Search a package and find available lines for it. + """ + message = None + response = None + search = self.actions_for("search") + package = search.package_from_scan(barcode) + if not package: + return response, message move_lines = self._find_location_move_lines( - zone_location, picking_type, package=package, order=order + zone_location, picking_type=picking_type, package=package, order=order ) - return first(move_lines) + if move_lines: + response = self._response_for_set_line_destination( + zone_location, picking_type, first(move_lines) + ) + else: + response = self.list_move_lines( + zone_location.id, picking_type.id, order=order + ) + message = self.msg_store.package_not_found() + return response, message - def _scan_source_product(self, zone_location, picking_type, product, order): + def _scan_source_product( + self, zone_location, picking_type, barcode, order="priority" + ): + """Search a product and find available lines for it. + """ + message = None + response = None + search = self.actions_for("search") + product = search.product_from_scan(barcode) + if not product: + return response, message move_lines = self._find_location_move_lines( - zone_location, picking_type, product=product, order=order + zone_location, picking_type=picking_type, product=product, order=order ) - return first(move_lines) + if move_lines: + response = self._response_for_set_line_destination( + zone_location, picking_type, first(move_lines) + ) + else: + response = self.list_move_lines( + zone_location.id, picking_type.id, order=order + ) + message = self.msg_store.product_not_found() + return response, message - def _scan_source_lot(self, zone_location, picking_type, lot, order): + def _scan_source_lot(self, zone_location, picking_type, barcode, order="priority"): + """Search a lot and find available lines for it. + """ + message = None + response = None + search = self.actions_for("search") + lot = search.lot_from_scan(barcode) + if not lot: + return response, message move_lines = self._find_location_move_lines( - zone_location, picking_type, lot=lot, order=order + zone_location, picking_type=picking_type, lot=lot, order=order ) - return first(move_lines) + if move_lines: + response = self._response_for_set_line_destination( + zone_location, picking_type, first(move_lines) + ) + else: + response = self.list_move_lines( + zone_location.id, picking_type.id, order=order + ) + message = self.msg_store.lot_not_found() + return response, message def scan_source(self, zone_location_id, picking_type_id, barcode, order="priority"): """Select a move line or narrow the list of move lines @@ -450,73 +537,25 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit 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() - ) - product, lot, package = self._find_product_in_location(location) - if len(product) > 1 or len(lot) > 1 or len(package) > 1: - response = self.list_move_lines(location.id, picking_type.id) - return self._response( - base_response=response, - message=self.msg_store.several_products_in_location(location), - ) - move_line = self._scan_source_location( - zone_location, - picking_type, - location, - order=order, - product=product, - lot=lot, - package=package, - ) - # 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.location_empty(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() + handlers = ( + # search by location 1st + self._scan_source_location, + # then by package + self._scan_source_package, + # then by product + self._scan_source_product, + # then by lot + self._scan_source_lot, + ) + for handler in handlers: + response, message = handler( + zone_location, picking_type, barcode, order=order ) - return self._response_for_set_line_destination( - zone_location, picking_type, move_line + if response: + return self._response(base_response=response, message=message) + response = self.list_move_lines(zone_location.id, picking_type.id, order=order) + return self._response( + base_response=response, message=self.msg_store.barcode_not_found() ) def _set_destination_location( From bf88d1de7f648f7f7b7f4c19311da205fea36f19 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 18 Dec 2020 11:05:11 +0100 Subject: [PATCH 481/710] shopfloor: improve service header validation * active arg can be a callable * fail only if 'mandatory' arg is True --- shopfloor/services/service.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 4108ba057d6..33f204e268d 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -428,12 +428,16 @@ def _validate_headers_update_work_context(self, request, method_name): extra_work_ctx = {} headers = request.httprequest.environ for rule, active in self._validation_rules: + if callable(active): + active = active(request, method_name) if not active: continue - header_name, coerce_func, ctx_value_handler_name = rule + header_name, coerce_func, ctx_value_handler_name, mandatory = rule try: header_value = coerce_func(headers.get(header_name)) except (TypeError, ValueError) as err: + if not mandatory: + continue raise BadRequest( "{} header validation error: {}".format(header_name, str(err)) ) @@ -454,16 +458,18 @@ def _validation_rules(self): ) MENU_ID_HEADER_RULE = ( - # header name, coerce func, ctx handler + # header name, coerce func, ctx handler, mandatory "HTTP_SERVICE_CTX_MENU_ID", int, "_work_ctx_get_menu_id", + True, ) PROFILE_ID_HEADER_RULE = ( - # header name, coerce func, ctx value handler + # header name, coerce func, ctx value handler, mandatory "HTTP_SERVICE_CTX_PROFILE_ID", int, "_work_ctx_get_profile_id", + True, ) def _work_ctx_get_menu_id(self, rec_id): From 7e74091a5d36a2da55788d5eb1837fc2b296c4f0 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 13 Jan 2021 13:48:36 +0000 Subject: [PATCH 482/710] shopfloor 13.0.1.5.4 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index b098d2d03b0..841cc196e97 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.3", + "version": "13.0.1.5.4", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From a487c295b600b5660645f556def9700ca7547ca0 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 14 Jan 2021 10:19:53 +0000 Subject: [PATCH 483/710] shopfloor 13.0.1.5.5 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 841cc196e97..63ac8224e59 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.4", + "version": "13.0.1.5.5", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From e5df1f7892133b23a5446da35bac4ccc640fc8ac Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 14 Jan 2021 13:37:52 +0000 Subject: [PATCH 484/710] shopfloor 13.0.1.6.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 63ac8224e59..a64a5924a16 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.5", + "version": "13.0.1.6.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From b4eedb6169fac193e1ac234ae3792b8e973c5054 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 20 Jan 2021 13:12:49 +0000 Subject: [PATCH 485/710] shopfloor 13.0.2.0.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index a64a5924a16..26c9d012eb9 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.6.0", + "version": "13.0.2.0.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 27723d3c5337c8f84e573e22ec82d0111ac18765 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 13 Jan 2021 13:54:25 +0100 Subject: [PATCH 486/710] Remove warehouse from profile The warehouse of the picking type only should be used. --- shopfloor/demo/shopfloor_profile_demo.xml | 2 -- shopfloor/i18n/shopfloor.pot | 5 ----- shopfloor/models/shopfloor_profile.py | 13 +------------ shopfloor/services/menu.py | 10 +--------- shopfloor/services/profile.py | 11 ----------- shopfloor/services/service.py | 14 ++++---------- shopfloor/tests/test_app.py | 10 +--------- shopfloor/tests/test_checkout_base.py | 2 +- shopfloor/tests/test_cluster_picking_base.py | 2 +- shopfloor/tests/test_cluster_picking_batch.py | 2 +- shopfloor/tests/test_db_logging.py | 2 +- shopfloor/tests/test_delivery_base.py | 2 +- .../test_location_content_transfer_base.py | 2 +- shopfloor/tests/test_menu.py | 18 ------------------ shopfloor/tests/test_picking_form.py | 2 +- shopfloor/tests/test_profile.py | 12 ++---------- .../tests/test_single_pack_transfer_base.py | 2 +- shopfloor/tests/test_zone_picking_base.py | 2 +- shopfloor/views/shopfloor_profile_views.xml | 3 --- 19 files changed, 18 insertions(+), 98 deletions(-) diff --git a/shopfloor/demo/shopfloor_profile_demo.xml b/shopfloor/demo/shopfloor_profile_demo.xml index 0275b3e335f..5e3b36a2873 100644 --- a/shopfloor/demo/shopfloor_profile_demo.xml +++ b/shopfloor/demo/shopfloor_profile_demo.xml @@ -1,10 +1,8 @@ Highbay Truck - Shelf 1 - diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 1a4809fa372..1d6a6575eb5 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -1338,11 +1338,6 @@ msgstr "" 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" diff --git a/shopfloor/models/shopfloor_profile.py b/shopfloor/models/shopfloor_profile.py index 2d490b3b3f5..029affc881f 100644 --- a/shopfloor/models/shopfloor_profile.py +++ b/shopfloor/models/shopfloor_profile.py @@ -1,6 +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 api, fields, models +from odoo import fields, models class ShopfloorProfile(models.Model): @@ -8,18 +8,7 @@ class ShopfloorProfile(models.Model): _description = "Shopfloor profile settings" name = fields.Char(required=True) - warehouse_id = fields.Many2one( - "stock.warehouse", - required=True, - default=lambda self: self._default_warehouse_id(), - ) 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): - wh = self.env["stock.warehouse"].search([]) - if len(wh) == 1: - return wh diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index 02a6a90edeb..d910a2f3112 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -35,20 +35,12 @@ def _get_base_search_domain(self): def _search(self, name_fragment=None): if not self.work.profile: - # we need to know the warehouse of the profile - # to load menus + # we need to know 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/services/profile.py b/shopfloor/services/profile.py index 55d2297937e..6724ecaca14 100644 --- a/shopfloor/services/profile.py +++ b/shopfloor/services/profile.py @@ -38,10 +38,6 @@ def _convert_one_record(self, record): return { "id": record.id, "name": record.name, - "warehouse": { - "id": record.warehouse_id.id, - "name": record.warehouse_id.name, - }, } @@ -81,11 +77,4 @@ def _record_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}, - }, - }, } diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 33f204e268d..aae3a9c231c 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -507,14 +507,8 @@ class BaseShopfloorProcess(AbstractComponent): _requires_header_profile = True 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? - picking_types = self.work.menu.picking_type_ids.filtered( - lambda pt: not pt.warehouse_id - or pt.warehouse_id == self.work.profile.warehouse_id - ) - return picking_types + """Return picking types for the menu""" + return self.work.menu.picking_type_ids @property def picking_types(self): @@ -522,8 +516,8 @@ def picking_types(self): 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( - self.work.menu.name, self.work.profile.warehouse_id.display_name + _("No operation types configured on menu {}.").format( + self.work.menu.name ) ) return self.work.picking_types diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py index c85d6e9cd16..8a41ad8695a 100644 --- a/shopfloor/tests/test_app.py +++ b/shopfloor/tests/test_app.py @@ -24,15 +24,7 @@ def test_user_config(self): response, data={ "profiles": [ - { - "id": profile.id, - "name": profile.name, - "warehouse": { - "id": profile.warehouse_id.id, - "name": profile.warehouse_id.name, - }, - } - for profile in profiles + {"id": profile.id, "name": profile.name} for profile in profiles ], "user_info": {"id": self.env.user.id, "name": self.env.user.name}, }, diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 4c6ffe65c74..16b1986688d 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -9,8 +9,8 @@ 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 + cls.wh = cls.picking_type.warehouse_id @classmethod def setUpClassBaseData(cls, *args, **kwargs): diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index 093322dc1f0..ae40265b871 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -10,8 +10,8 @@ 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 + cls.wh = cls.picking_type.warehouse_id @classmethod def setUpClassBaseData(cls, *args, **kwargs): diff --git a/shopfloor/tests/test_cluster_picking_batch.py b/shopfloor/tests/test_cluster_picking_batch.py index fc95b63af72..d30fca2dadd 100644 --- a/shopfloor/tests/test_cluster_picking_batch.py +++ b/shopfloor/tests/test_cluster_picking_batch.py @@ -10,8 +10,8 @@ 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 + cls.wh = cls.picking_type.warehouse_id @classmethod def setUpClassBaseData(cls, *args, **kwargs): diff --git a/shopfloor/tests/test_db_logging.py b/shopfloor/tests/test_db_logging.py index 737feb3861b..3efbbe666c1 100644 --- a/shopfloor/tests/test_db_logging.py +++ b/shopfloor/tests/test_db_logging.py @@ -18,8 +18,8 @@ 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 + cls.wh = cls.picking_type.warehouse_id 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() diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index a17f2f37fe1..8c80fd9f047 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -10,8 +10,8 @@ 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.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id @classmethod def setUpClassBaseData(cls, *args, **kwargs): diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index cbc1c2c852e..54568133e78 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -10,8 +10,8 @@ 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 + cls.wh = cls.picking_type.warehouse_id @classmethod def setUpClassBaseData(cls, *args, **kwargs): diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py index c27b937bdef..919e2b78479 100644 --- a/shopfloor/tests/test_menu.py +++ b/shopfloor/tests/test_menu.py @@ -25,21 +25,3 @@ def test_menu_search_restricted(self): my_menus = menus - menus_without_profile 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"].sudo().search([]) - # should not be visible as the profile has another wh - menu_different_wh = menus[0] - 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 - 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) diff --git a/shopfloor/tests/test_picking_form.py b/shopfloor/tests/test_picking_form.py index 8ae8ba677e1..67e4f8696b3 100644 --- a/shopfloor/tests/test_picking_form.py +++ b/shopfloor/tests/test_picking_form.py @@ -9,8 +9,8 @@ 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 + cls.wh = cls.picking_type.warehouse_id @classmethod def setUpClassBaseData(cls): diff --git a/shopfloor/tests/test_profile.py b/shopfloor/tests/test_profile.py index 52189578f72..a7fc094ad6e 100644 --- a/shopfloor/tests/test_profile.py +++ b/shopfloor/tests/test_profile.py @@ -19,16 +19,8 @@ def test_profile_search(self): 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"}, - }, + {"id": self.ANY, "name": "Highbay Truck"}, + {"id": self.ANY, "name": "Shelf 1"}, ], }, ) diff --git a/shopfloor/tests/test_single_pack_transfer_base.py b/shopfloor/tests/test_single_pack_transfer_base.py index d512d10bcc4..05f76f166be 100644 --- a/shopfloor/tests/test_single_pack_transfer_base.py +++ b/shopfloor/tests/test_single_pack_transfer_base.py @@ -10,8 +10,8 @@ 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 + cls.wh = cls.picking_type.warehouse_id @classmethod def setUpClassBaseData(cls, *args, **kwargs): diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 69dbc125ba3..317f06730e9 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -9,8 +9,8 @@ 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 + cls.wh = cls.picking_type.warehouse_id @classmethod def setUpClassUsers(cls): diff --git a/shopfloor/views/shopfloor_profile_views.xml b/shopfloor/views/shopfloor_profile_views.xml index 7a95ac5506f..1e2d9beee94 100644 --- a/shopfloor/views/shopfloor_profile_views.xml +++ b/shopfloor/views/shopfloor_profile_views.xml @@ -6,7 +6,6 @@ - @@ -27,7 +26,6 @@
- @@ -41,7 +39,6 @@ - Date: Wed, 13 Jan 2021 14:39:29 +0100 Subject: [PATCH 487/710] Change shopfloor_menu.profile_ids to Many2one * It allows grouping and providing stats per menu/profile (example: number of move lines to process for a profile). * A migration script changes the relation and duplicates a menu if it had several profiles --- shopfloor/__manifest__.py | 2 +- shopfloor/i18n/shopfloor.pot | 10 ++--- .../migrations/13.0.2.1.0/post-migration.py | 39 +++++++++++++++++++ shopfloor/models/shopfloor_menu.py | 4 +- shopfloor/models/shopfloor_profile.py | 7 +++- shopfloor/services/menu.py | 4 +- shopfloor/tests/test_menu.py | 2 +- shopfloor/tests/test_user.py | 4 +- shopfloor/views/shopfloor_menu.xml | 14 ++----- 9 files changed, 60 insertions(+), 26 deletions(-) create mode 100644 shopfloor/migrations/13.0.2.1.0/post-migration.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 26c9d012eb9..bfd8718f4f6 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.2.0.0", + "version": "13.0.2.1.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 1d6a6575eb5..f206574a9f3 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -817,9 +817,9 @@ 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" +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__profile_id +#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_profil +msgid "Profile" msgstr "" #. module: shopfloor @@ -1334,8 +1334,8 @@ msgid "User" msgstr "" #. module: shopfloor -#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_ids -msgid "Visible for these profiles" +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_id +msgid "Visible on this profile only" msgstr "" #. module: shopfloor diff --git a/shopfloor/migrations/13.0.2.1.0/post-migration.py b/shopfloor/migrations/13.0.2.1.0/post-migration.py new file mode 100644 index 00000000000..4a8a627efbd --- /dev/null +++ b/shopfloor/migrations/13.0.2.1.0/post-migration.py @@ -0,0 +1,39 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + env = api.Environment(cr, SUPERUSER_ID, {}) + cr.execute( + """ + SELECT shopfloor_menu_id, shopfloor_profile_id + FROM shopfloor_menu_shopfloor_profile_rel + """ + ) + menu_profile_ids = {} + for menu_id, profile_id in cr.fetchall(): + menu_profile_ids.setdefault(menu_id, []) + menu_profile_ids[menu_id].append(profile_id) + + for menu_id, profile_ids in menu_profile_ids.items(): + if len(profile_ids) > 1: + _logger.warn( + "menu id %s was linked with 2 profiles (ids: %s)," + " only one is now possible now, menu has been" + " duplicated for each profile", + menu_id, + profile_ids, + ) + index = 1 + for profile_id in profile_ids: + menu = env["shopfloor.menu"].browse(menu_id) + if index > 1: + menu = menu.copy({"name": "{} ({})".format(menu.name, index)}) + menu.profile_id = profile_id + index += 1 diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index e398fe32cf4..0e154243d09 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -27,8 +27,8 @@ class ShopfloorMenu(models.Model): name = fields.Char(translate=True) sequence = fields.Integer() - profile_ids = fields.Many2many( - "shopfloor.profile", string="Profiles", help="Visible for these profiles" + profile_id = fields.Many2one( + "shopfloor.profile", string="Profile", help="Visible on this profile only" ) picking_type_ids = fields.Many2many( comodel_name="stock.picking.type", string="Operation Types", required=True diff --git a/shopfloor/models/shopfloor_profile.py b/shopfloor/models/shopfloor_profile.py index 029affc881f..060df4355e7 100644 --- a/shopfloor/models/shopfloor_profile.py +++ b/shopfloor/models/shopfloor_profile.py @@ -8,7 +8,10 @@ class ShopfloorProfile(models.Model): _description = "Shopfloor profile settings" name = fields.Char(required=True) - menu_ids = fields.Many2many( - "shopfloor.menu", string="Menus", help="Menus visible for this profile" + menu_ids = fields.One2many( + comodel_name="shopfloor.menu", + inverse_name="profile_id", + string="Menus", + help="Menus visible for this profile", ) active = fields.Boolean(default=True) diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index d910a2f3112..ca353b7d847 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -27,8 +27,8 @@ def _get_base_search_domain(self): base_domain, [ "|", - ("profile_ids", "=", False), - ("profile_ids", "in", self.work.profile.ids), + ("profile_id", "=", False), + ("profile_id", "=", self.work.profile.id), ], ] ) diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py index 919e2b78479..652fcaee8cc 100644 --- a/shopfloor/tests/test_menu.py +++ b/shopfloor/tests/test_menu.py @@ -19,7 +19,7 @@ def test_menu_search_restricted(self): 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 + menus_without_profile.profile_id = other_profile response = self.service.dispatch("search") diff --git a/shopfloor/tests/test_user.py b/shopfloor/tests/test_user.py index 384d8888933..736bb86e1ae 100644 --- a/shopfloor/tests/test_user.py +++ b/shopfloor/tests/test_user.py @@ -30,8 +30,8 @@ def test_menu_by_profile(self): # 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 + menu.profile_id = self.profile + (menus - menu).profile_id = self.profile2 response = self.service.dispatch("menu") self.assert_response( diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 552c2e71937..da7468561e0 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -8,11 +8,7 @@ - + - + - + Date: Thu, 14 Jan 2021 08:58:23 +0100 Subject: [PATCH 488/710] Add group by on menu search view --- shopfloor/views/shopfloor_menu.xml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index da7468561e0..3f6c6e331bd 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -83,14 +83,28 @@ - + + + + + From b5a10bbdb94054b54081f672ddd122ffa606a946 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 18 Jan 2021 10:01:55 +0100 Subject: [PATCH 489/710] Add plantuml diagram for single pack transfer --- .../docs/single_pack_transfer_diag_seq.png | Bin 0 -> 29489 bytes .../docs/single_pack_transfer_diag_seq.txt | 36 ++++++++++++++++++ shopfloor/services/delivery.py | 2 + shopfloor/services/single_pack_transfer.py | 11 ++++-- shopfloor/services/zone_picking.py | 2 + 5 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 shopfloor/docs/single_pack_transfer_diag_seq.png create mode 100644 shopfloor/docs/single_pack_transfer_diag_seq.txt diff --git a/shopfloor/docs/single_pack_transfer_diag_seq.png b/shopfloor/docs/single_pack_transfer_diag_seq.png new file mode 100644 index 0000000000000000000000000000000000000000..60b3c9202447d3b697b4f86942d3fd41c6002f3a GIT binary patch literal 29489 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 SmpYL0PWh_(m9)z@9{wNTjRR-^ literal 0 HcmV?d00001 diff --git a/shopfloor/docs/single_pack_transfer_diag_seq.txt b/shopfloor/docs/single_pack_transfer_diag_seq.txt new file mode 100644 index 00000000000..248d8734d0d --- /dev/null +++ b/shopfloor/docs/single_pack_transfer_diag_seq.txt @@ -0,0 +1,36 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml single_pack_transfer_diag_seq.txt +# + +@startuml + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Single Pack Transfer scenario + +== /start == +start -> scan_location: **/start**(barcode[pack|location], confirmation=False) +start -> start: **/start**(barcode[pack|location], confirmation=False) + +== /cancel == +scan_location -> start: **/cancel**(package_level_id) + +== /validate == +scan_location -> scan_location: **/validate**(package_level_id, location_barcode, confirmation=False) +scan_location -> start: **/validate**(package_level_id, location_barcode, confirmation=False) + +@enduml diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 8390a963cc5..59c1a52052d 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -19,6 +19,8 @@ class Delivery(Component): You will find a sequence diagram describing states and endpoints relationships [here](../docs/delivery_diag_seq.png). + Keep [the sequence diagram](../docs/delivery_diag_seq.txt) + up-to-date if you change endpoints. Expected: diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 917d47e8a7e..4b88a35e26c 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -8,7 +8,13 @@ class SinglePackTransfer(Component): - """Methods for the Single Pack Transfer Process""" + """Methods for the Single Pack Transfer Process + + You will find a sequence diagram describing states and endpoints + relationships [here](../docs/single_pack_transfer_diag_seq.png). + Keep [the sequence diagram](../docs/single_pack_transfer_diag_seq.txt) + up-to-date if you change endpoints. + """ _inherit = "base.shopfloor.process" _name = "shopfloor.single.pack.transfer" @@ -51,9 +57,6 @@ def _response_for_scan_location( 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, confirmation=False): search = self.actions_for("search") picking_types = self.picking_types diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 99b244e8dd7..ab7616653b6 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -20,6 +20,8 @@ class ZonePicking(Component): You will find a sequence diagram describing states and endpoints relationships [here](../docs/zone_picking_diag_seq.png). + Keep [the sequence diagram](../docs/delivery_diag_seq.txt) + up-to-date if you change endpoints. Note: From fbdef60dce267315951a5e74bed4a2f93db03455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 13 Jan 2021 12:04:01 +0100 Subject: [PATCH 490/710] [FIX] shopfloor: do not create operation if destination is invalid Both 'Single pack transfer' and 'Location content transfer' are fixed. --- shopfloor/actions/message.py | 15 ++++++++ .../services/location_content_transfer.py | 11 ++++++ shopfloor/services/single_pack_transfer.py | 16 +++++---- shopfloor/tests/__init__.py | 1 + .../test_location_content_transfer_putaway.py | 36 +++++++++++++++++++ .../test_single_pack_transfer_putaway.py | 34 ++++++++++++++++++ 6 files changed, 107 insertions(+), 6 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 38abf195041..a4acfc3ed9e 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -227,6 +227,13 @@ def no_putaway_destination_available(self): "body": _("No putaway destination is available."), } + def package_unable_to_transfer(self, pack): + return { + "message_type": "error", + "body": _("The package %s cannot be transferred with this scenario.") + % pack.name, + } + def unrecoverable_error(self): return { "message_type": "error", @@ -359,6 +366,14 @@ def location_content_transfer_complete(self, location_src, location_dest): ), } + def location_content_unable_to_transfer(self, location_dest): + return { + "message_type": "error", + "body": _( + "The content of {} cannot be transferred with this scenario." + ).format(location_dest.name), + } + def picking_already_started_in_location(self, pickings): return { "message_type": "error", diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 4fafb5e0c21..ba4e92e4eaf 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -353,6 +353,17 @@ def scan_location(self, barcode): ) pickings = new_moves.mapped("picking_id") move_lines = new_moves.move_line_ids + for move_line in move_lines: + if not move_line.location_dest_id.is_sublocation_of( + menu.picking_type_ids.default_location_dest_id + ): + savepoint.rollback() + + return self._response_for_start( + message=self.msg_store.location_content_unable_to_transfer( + location + ) + ) if self.work.menu.ignore_no_putaway_available and self._no_putaway_available( move_lines diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 4b88a35e26c..c8098fbf122 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -134,16 +134,20 @@ def start(self, barcode, confirmation=False): 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) + message = self.msg_store.no_pending_operation_for_pack(package) + if not package_level and self.work.menu.allow_move_create: + package_level = self._create_package_level(package) + if not package_level.location_dest_id.is_sublocation_of( + picking_types.default_location_dest_id + ): + package_level = None + savepoint.rollback() + message = self.msg_store.package_unable_to_transfer(package) if not package_level: # restore any unreserved move/package level savepoint.rollback() - return self._response_for_start( - message=self.msg_store.no_pending_operation_for_pack(package) - ) + return self._response_for_start(message=message) if self.work.menu.ignore_no_putaway_available and self._no_putaway_available( package_level ): diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 6013d938859..3c67d70a693 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -48,6 +48,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_putaway from . import test_location_content_transfer_mix from . import test_zone_picking_base from . import test_zone_picking_start diff --git a/shopfloor/tests/test_location_content_transfer_putaway.py b/shopfloor/tests/test_location_content_transfer_putaway.py index 6421f71babe..86a6898cf5c 100644 --- a/shopfloor/tests/test_location_content_transfer_putaway.py +++ b/shopfloor/tests/test_location_content_transfer_putaway.py @@ -103,3 +103,39 @@ def test_ignore_no_putaway_available(self): ) # no package level created to move the package self.assertFalse(package_levels) + + def test_putaway_move_dest_not_child_of_picking_type_dest(self): + """Putaway is applied on move but the destination location is not a + child of the default picking type destination location. + """ + # Change the default destination location of the picking type + # to get it outside of the putaway destination + self.picking_type.sudo().default_location_dest_id = self.main_pallets_location + # Create a standard putaway to move the package from pallet storage + # to a unrelated one (outside of the pallet storage tree) + self.env["stock.putaway.rule"].sudo().create( + { + "product_id": self.product_a.id, + "location_in_id": self.picking_type.default_location_dest_id.id, + "location_out_id": self.env.ref("stock.location_refrigerator_small").id, + } + ) + # Check the result + existing_moves = self.env["stock.move"].search( + [("location_id", "=", self.test_loc.id), ("state", "=", "assigned")] + ) + response = self.service.dispatch( + "scan_location", params={"barcode": self.test_loc.barcode} + ) + self.assert_response( + response, + next_state="start", + data=self.ANY, + message=self.service.msg_store.location_content_unable_to_transfer( + self.test_loc + ), + ) + current_moves = self.env["stock.move"].search( + [("location_id", "=", self.test_loc.id), ("state", "=", "assigned")] + ) + self.assertEqual(existing_moves, current_moves) diff --git a/shopfloor/tests/test_single_pack_transfer_putaway.py b/shopfloor/tests/test_single_pack_transfer_putaway.py index 586fb145a93..caced595ff0 100644 --- a/shopfloor/tests/test_single_pack_transfer_putaway.py +++ b/shopfloor/tests/test_single_pack_transfer_putaway.py @@ -76,3 +76,37 @@ def test_ignore_no_putaway_available(self): ) # no package level created to move the package self.assertFalse(package_levels) + + def test_putaway_move_dest_not_child_of_picking_type_dest(self): + """Putaway is applied on move but the destination location is not a + child of the default picking type destination location. + """ + # Change the default destination location of the picking type + # to get it outside of the putaway destination + self.picking_type.sudo().default_location_dest_id = self.main_pallets_location + # Create a standard putaway to move the package from pallet storage + # to a unrelated one (outside of the pallet storage tree) + self.env["stock.putaway.rule"].sudo().create( + { + "product_id": self.product_a.id, + "location_in_id": self.picking_type.default_location_dest_id.id, + "location_out_id": self.env.ref("stock.location_refrigerator_small").id, + } + ) + # Check the result + existing_package_levels = self.env["stock.package_level"].search( + [("package_id", "=", self.package.id)] + ) + response = self.service.dispatch( + "start", params={"barcode": self.shelf1.barcode} + ) + self.assert_response( + response, + next_state="start", + data=self.ANY, + message=self.service.msg_store.package_unable_to_transfer(self.package), + ) + current_package_levels = self.env["stock.package_level"].search( + [("package_id", "=", self.package.id)] + ) + self.assertEqual(existing_package_levels, current_package_levels) From f61b4cf82d58ba8d97bd10120d8e120a9296f5b8 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 22 Jan 2021 17:55:17 +0000 Subject: [PATCH 491/710] shopfloor 13.0.2.1.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index bfd8718f4f6..d9bb05170ea 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.2.1.0", + "version": "13.0.2.1.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 7f3951b04ae735a21b157844d75d033a8e60476a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 25 Jan 2021 09:53:40 +0100 Subject: [PATCH 492/710] Rename .txt to .plantuml on plantuml diagrams --- .../docs/{delivery_diag_seq.txt => delivery_diag_seq.plantuml} | 2 +- ...sfer_diag_seq.txt => single_pack_transfer_diag_seq.plantuml} | 2 +- ...zone_picking_diag_seq.txt => zone_picking_diag_seq.plantuml} | 2 +- shopfloor/services/delivery.py | 2 +- shopfloor/services/single_pack_transfer.py | 2 +- shopfloor/services/zone_picking.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename shopfloor/docs/{delivery_diag_seq.txt => delivery_diag_seq.plantuml} (97%) rename shopfloor/docs/{single_pack_transfer_diag_seq.txt => single_pack_transfer_diag_seq.plantuml} (94%) rename shopfloor/docs/{zone_picking_diag_seq.txt => zone_picking_diag_seq.plantuml} (98%) diff --git a/shopfloor/docs/delivery_diag_seq.txt b/shopfloor/docs/delivery_diag_seq.plantuml similarity index 97% rename from shopfloor/docs/delivery_diag_seq.txt rename to shopfloor/docs/delivery_diag_seq.plantuml index 0747eb01d2a..5bb786c0f88 100644 --- a/shopfloor/docs/delivery_diag_seq.txt +++ b/shopfloor/docs/delivery_diag_seq.plantuml @@ -1,7 +1,7 @@ # Diagram to generate with PlantUML (https://plantuml.com/) # # $ sudo apt install plantuml -# $ plantuml delivery_diag_seq.txt +# $ plantuml delivery_diag_seq.plantuml # @startuml diff --git a/shopfloor/docs/single_pack_transfer_diag_seq.txt b/shopfloor/docs/single_pack_transfer_diag_seq.plantuml similarity index 94% rename from shopfloor/docs/single_pack_transfer_diag_seq.txt rename to shopfloor/docs/single_pack_transfer_diag_seq.plantuml index 248d8734d0d..35865182d4c 100644 --- a/shopfloor/docs/single_pack_transfer_diag_seq.txt +++ b/shopfloor/docs/single_pack_transfer_diag_seq.plantuml @@ -1,7 +1,7 @@ # Diagram to generate with PlantUML (https://plantuml.com/) # # $ sudo apt install plantuml -# $ plantuml single_pack_transfer_diag_seq.txt +# $ plantuml single_pack_transfer_diag_seq.plantuml # @startuml diff --git a/shopfloor/docs/zone_picking_diag_seq.txt b/shopfloor/docs/zone_picking_diag_seq.plantuml similarity index 98% rename from shopfloor/docs/zone_picking_diag_seq.txt rename to shopfloor/docs/zone_picking_diag_seq.plantuml index 1884644dcd4..712b1040943 100644 --- a/shopfloor/docs/zone_picking_diag_seq.txt +++ b/shopfloor/docs/zone_picking_diag_seq.plantuml @@ -1,7 +1,7 @@ # Diagram to generate with PlantUML (https://plantuml.com/) # # $ sudo apt install plantuml -# $ plantuml zone_picking_diag_seq.txt +# $ plantuml zone_picking_diag_seq.plantuml # @startuml diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 59c1a52052d..072b6bc710d 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -19,7 +19,7 @@ class Delivery(Component): You will find a sequence diagram describing states and endpoints relationships [here](../docs/delivery_diag_seq.png). - Keep [the sequence diagram](../docs/delivery_diag_seq.txt) + Keep [the sequence diagram](../docs/delivery_diag_seq.plantuml) up-to-date if you change endpoints. Expected: diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index c8098fbf122..415847b06ff 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -12,7 +12,7 @@ class SinglePackTransfer(Component): You will find a sequence diagram describing states and endpoints relationships [here](../docs/single_pack_transfer_diag_seq.png). - Keep [the sequence diagram](../docs/single_pack_transfer_diag_seq.txt) + Keep [the sequence diagram](../docs/single_pack_transfer_diag_seq.plantuml) up-to-date if you change endpoints. """ diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index ab7616653b6..d90c9e35a4f 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -20,7 +20,7 @@ class ZonePicking(Component): You will find a sequence diagram describing states and endpoints relationships [here](../docs/zone_picking_diag_seq.png). - Keep [the sequence diagram](../docs/delivery_diag_seq.txt) + Keep [the sequence diagram](../docs/delivery_diag_seq.plantuml) up-to-date if you change endpoints. Note: From f6094927d212d99030c8ee0e8e6687a6d1133675 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 25 Jan 2021 13:01:57 +0100 Subject: [PATCH 493/710] cluster picking: Add PlantUML diagram --- .../docs/cluster_picking_diag_seq.plantuml | 112 ++++++++++++++++++ shopfloor/docs/cluster_picking_diag_seq.png | Bin 0 -> 279898 bytes shopfloor/services/cluster_picking.py | 5 +- 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 shopfloor/docs/cluster_picking_diag_seq.plantuml create mode 100644 shopfloor/docs/cluster_picking_diag_seq.png diff --git a/shopfloor/docs/cluster_picking_diag_seq.plantuml b/shopfloor/docs/cluster_picking_diag_seq.plantuml new file mode 100644 index 00000000000..71763b2b45b --- /dev/null +++ b/shopfloor/docs/cluster_picking_diag_seq.plantuml @@ -0,0 +1,112 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml cluster_picking_diag_seq.plantuml +# + +@startuml +participant start +participant manual_selection +participant confirm_start + +participant start_line +participant scan_destination +participant zero_check +participant stock_issue +participant change_pack_lot + +participant unload_all +participant confirm_unload_all +participant unload_single +participant unload_set_destination +participant confirm_unload_set_destination + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Cluster Picking scenario + + +== Batch Transfer Selection == +start -[#red]> start: **/find_batch** \n(error) +start -> confirm_start: **/find_batch** + +start -> manual_selection: **/list_batch** + +manual_selection -[#red]> manual_selection: **/select(picking_batch_id)** \n(error) +manual_selection -> confirm_start: **/select(picking_batch_id)** +manual_selection -> start: Button **Back** (client-side) + +confirm_start -> start_line: **/confirm_start(picking_batch_id)** +confirm_start -> unload_all: **/confirm_start(picking_batch_id)** \n(we reopen a batch with all lines picked and have to be unloaded in the same destination) +confirm_start -> unload_single: **/confirm_start(picking_batch_id)** \n(we reopen a batch with all lines picked and have to be unloaded in different destinations) +confirm_start -> start: **/unassign(picking_batch_id)** + +== Picking == + +start_line -[#red]> start_line: **/scan_line(picking_batch_id, move_line_id, barcode[package|product|lot])** \n(error) +start_line -> scan_destination: **/scan_line(picking_batch_id, move_line_id, barcode[package|product|lot])** \n(error) + +scan_destination -[#red]> scan_destination: **/scan_destination_pack(picking_batch_id, move_line_id, barcode[package], quantity)** \n(error) +scan_destination -> start_line: **/scan_destination_pack(picking_batch_id, move_line_id, barcode[package], quantity)** \n(other lines to pick) +scan_destination -> zero_check: **/scan_destination_pack(picking_batch_id, move_line_id, barcode[package], quantity)** \n(source location is now empty) +scan_destination -> unload_all: **/scan_destination_pack(picking_batch_id, move_line_id, barcode[package], quantity)** \n(all lines picked and same destination) +scan_destination -> unload_single: **/scan_destination_pack(picking_batch_id, move_line_id, barcode[package], quantity)** \n(all lines picked and different destinations) + +start_line -> unload_all: **/prepare_unload(picking_batch_id)** \n(all lines picked and same destination) +start_line -> unload_single: **/prepare_unload(picking_batch_id)** \n(all lines picked and different destinations) + +start_line -> start_line: **/skip_line(picking_batch_id, move_line_id)** + +start_line -> stock_issue: Button *Stock Issue* (client-side) +stock_issue -> start_line: **/stock_issue(picking_batch_id, move_line_id)** \n(other lines to pick) +stock_issue -> unload_all: **/stock_issue(picking_batch_id, move_line_id)** \n(all lines picked and same destination) +stock_issue -> unload_single: **/stock_issue(picking_batch_id, move_line_id)** \n(all lines picked and different destinations) + +zero_check -> start_line: **/is_zero(picking_batch_id, move_line_id, zero[bool])** \n(other lines to pick) +zero_check -> unload_all: **/is_zero(picking_batch_id, move_line_id, zero[bool])** \n(all lines picked and same destination) +zero_check -> unload_single: **/is_zero(picking_batch_id, move_line_id, zero[bool])** \n(all lines picked and different destinations) + +start_line -> change_pack_lot: Button *Change Package/Lot* (client-side) +change_pack_lot -[#red]> change_pack_lot: **/change_pack_lot(picking_batch_id, move_line_id, barcode[package|lot])** \n(error) +change_pack_lot -> scan_destination: **/change_pack_lot(picking_batch_id, move_line_id, barcode[package|lot])** + +== Unloading == + +unload_all -[#red]> unload_all: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(error) +unload_all -> start_line: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(unloaded, batch contains other lines to pick) +unload_all -> unload_single: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(lines have different destinations after all) +unload_all -> confirm_unload_all: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(change of destination to confirm) +unload_all -> start: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(batch finished) + +confirm_unload_all -[#red]> unload_all: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(error) +confirm_unload_all -> start_line: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(unloaded, batch contains other lines to pick) +confirm_unload_all -> unload_single: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(lines have different destinations after all) +confirm_unload_all -> start: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(batch finished) + +unload_all -> unload_single: **/unload_split(picking_batch_id)** + +unload_single -[#red]> unload_single: **/unload_scan_pack(picking_batch_id, package_id, barcode[location])** \n(error) +unload_single -> start_line: **/unload_scan_pack(picking_batch_id, package_id, barcode[location])** \n(package not found and still have lines to pick) +unload_single -> unload_set_destination: **/unload_scan_pack(picking_batch_id, package_id, barcode[location])** \n(scan is ok, has to set a destination) + +unload_set_destination -[#red]> unload_single: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(error) +unload_set_destination -> confirm_unload_set_destination: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(change of destination needs confirmation) +unload_set_destination -> start_line: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(batch has other lines to pick) +unload_set_destination -> start: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(batch finished) +confirm_unload_set_destination -[#red]> unload_single: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(error) +confirm_unload_set_destination -> start_line: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(batch has other lines to pick) +confirm_unload_set_destination -> start: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(batch finished) + +@enduml diff --git a/shopfloor/docs/cluster_picking_diag_seq.png b/shopfloor/docs/cluster_picking_diag_seq.png new file mode 100644 index 0000000000000000000000000000000000000000..64b85bd198d5bbd8a1fdccecfbd9086236c3288a GIT binary patch literal 279898 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
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

lwO;MSrj&hlg@%YugZ8Z02Z(nt`N!g&DgnnIWT2`DTZwx!rs|Z}|3Z(Zfw< zP;H6jDgyM+=Hhi{$*&73)UhSNrB5@r%bNr?UH>$+KjvW>FT(=H?HL(Qt&-sed=U_K zX_;$tO|OpTWu-(cu8P^92xoghHN1 zR8Q?XeWK202s_A#i9EFxz3JnzaR21>SQ|QW2qbe*ePpz>q4ZuHN^fE%<)}igWivTn zF$h05)J9G_1Lqt=*B&U!9QDTgFFEp1adKyOcTkSEU-6d0u{|Y1K4KWkY7DYjh_O zJr%`%ov$=&enLBPJf*;8jicsj(pW||LFF?@m&`aCPuc&eCS`I!Y3cR)tlvblX~RgF zfxf<}(I|UL*wgw#1%)na%2$&(#t<%-?;8?giQT4 ziKr;))`kmFj(7cW{jCbt!g=(1r%rK9G`|^+uspD#VudXU&6{>P3eP>D<}y#rjb%)X zAIu~bY%AyoouIE1)p7KQz1wLJX-p8Sp$pxkr?oor|5V8qpmC7IoYUGjwg&=%p;3#1 z(%e#jtWMKy+*GY?00EZHJQF8IJ~QivgDu6ph{Gy5FFJ-lu{Cr535XIMUtz6sWCAGY zd>YA#;N=((fb0aaQ`n>(#!)=)hsa1RkBA8@K>eJ*^H+>^Q)wv-p{f34Ts?-}qlP7p zvEI)M$T;5%~zX6EHl7^eJCRkz2jl()j8%|-;zOz6fxOz1Db_x5?G&cicB zH9;t<(_;ZOkeEV1zLk%+Hv+>KJ$Fw^+dJ7oCh~_JU7OL#bWwic2|p&Oq>R<^1^Fpa z&S>EQ;S#!valg3{1o#tbN1`K@@6;vy0j*c@x1@>!zD7* zB1f{K9f&%GVJCVr$VLPs#Z+kN-GZcG`gDI>wW+~m-k3Mpn_j+ZcCd3|!GzsBqj_7e zEpJxT8nj;B(G%~pO?O3PRUr>~nHG>iO4Z#Mk;pYwu{3J$s(eltmh&PLEky}3tA}#c zKEC!A(sz&`C)e{;6+b(mJYE1udZ_2|`UJO^c@|bu9-hXL>6#TDrG;(k-wA9&q>c@M zORhmIWu--bDVy@4wyBYm}a3aye7#L9sQ1gwx?JhN*Q z1tcJYEn>-g1_soBCwHIjaZjRz1TxPDg^oyVe?B9<3G;yFg;f1Y@rPoy-n?Ol1JDuT4*-vUQ6LRlGj_hd>9 z*OQoYSSD*!6$=d{pMd)tZ!CV4Q7)4`81ReE>}J=7Eg#Na{3FL4pRMnAufJw=rKAL& zttqHdoC;rkUGixtQ3@Dcxk-xutD`dxd`&i|0o{}{VpFARp|S1_Sb;6 zmr={Dviw+TUmhyPrgueXsPxP472v+tZtxcOR#eK=Vg3ON#yVh|K1fJ>G}zXul6NO^e61 zk#jLBt0TG#dwCJ%15-+5^w4V3(>g!o_?P&fugO)r)jW|hD0)HmspaS^AD{j3h$tbdA~Nu9b(EtbG?h z_2)S{ZtTi~SH;D}HGQR5D={@R&tLyI!G?OGt8LfviGHaFHHGn*t|?sYa5*|U!FK<3 zhl;FTTSa)$TvqLALgI85X+O#by5g&GW|1p2t1qQjhzjVR?JFMh0%q? zE+o`%k!X~ssFkOdUPTZeY1a!+)cP>sY6-u0ulHm30v^PAP-tpeT3R|7L(~x|6gjqS z3ZO-=pFoqP6e^S})GUM*TA4e&Ar7bFl=;2c&1U4@92xx$by9_C$Yt5?OOSgINoGUL z?2hM5=#ip0#1IFxAapXpyMS^Krb|~SAktSKw!1|)6d84ZIY%cedX?hb?Y+k9aXTIp zs}lrOzOm}KffU9ccA)->oc#iLY!Cp#Tn9H0<1a72A@<+A zeE96;x#{UffGS|-KnWDFKra)qQ3bU`Raw=oYMbhjYKD(gf&fDEHA=GxCEsgmu+jiI$H)0@*72!Y-Fd8qCB}ta_bulL;r)tr7 zq3pe;c`cIzxy0V>bK42Q!_9)gW@N9}cjB%WB6Z08Pj%$TE=0Z)k87`ZCPCp%RPwOm zTLO_RdH`|%4Tya=3M9!rs?PcXnG@v6<%{;jND%Q92qgK&p|=js{_q9itI#VDuR-u2 zb9W#;q~;prGB3o-5dzW2g+OG4u5>^zMsTQq{~&S!B1r@ubRyhBwg*j8w=YB9U4uMH zy9kLC61Wy|9>3= z@aSKk*sFh`%x9gS17^RTzR#cY^P-fS{iBvkX`k9rM{SP`kI>H_L5cgw9Fmy)h2T#b z&8wi}(|vhDS_lR=mgymOZWn&b(%H}MHPb)*QvljaCR-@WL6_ASa@jO!*75X$HfGEx}A#@=#zF^YZe5M&}dRR{*Pd z^&%uu1}MfAN5213@V4n%DCc8za9Bpor$DbJXl0y{e~=HR5DyP6hcN$f5PRxRmA7A= zcaTEVpL9VTxb>OeR%5dG`TNs?3st}#dU|?*DarrY$%Fwq#m;&4?OSg}3*{Xu3X7$W z;F1?GF7ujkP|b62DWV&O)wWy`zmX2P;m(JC!bM&35>e>QtSYK ztUGAa6%BlNn}??&KR+Meis1cL;|tX5T}!u*Cnr?FF6K^UK_B8Vm{-?**ege3y_NdXW+HeRoSczgOqtj`AprgS%073yQ6W=;#g1yoSO&^6L@3VAYq@EN1-41w2AI zE#7HpXqd<8=EMvj{Zp!>@86%4&T;uE5D(Q&2B5Fn!nyWF%+y#j_8yR#F@8bZ%T$kONWba3PCikXnVG4Z zWBfeFCB&$B0ntmSstSf(x~eib!6XaOFG+M=We(Z@`LBnN+M|o_fgq%T;Gq6hR!#;K z3dI-;d#Lerh!kp7S>u9JO+&;CeZ7Y_Q~<-O2a^ft^&PKseG|Bj!lp7x!D#4;>e#Ad zo>fU3m>2`Y!srz$e0+R!EM=ghuzCUXhgZKv5Kc|0JsJTa*CU@olW z;P@?Q-?ajxC-lYh(Qh_DGqtHGZ0Ni82=O$7D#4>y{H&wkasbv*(4fpp0$kgx?-%iz^{)P85kHs%l9YU0F{rx%;S)%bsZK84wWcI2I$#{ z+ENNVaST%%TcsJX%VZEGA|!m3CC|&tYyNh)@a-)Xyi=8Y*w|=T{Zb7GYpQGY^z`Po z^TU*TTX?yr{Fj%P-;@ISz9|J`m2W`9?sRyIXbQ0T|0yhVV0mDe24A0(`Jwpxf&u^6OKVLXJ+YTG4WLQ`G{P8Yu_& z5Ug3xaTwBW#jE#*|U1|~hU zYsmDSf`Fs-?)zMuYSQCz)QSCGCD=%wft+<=6|H650KdE<=(NY;T>l1c%c!=Cuw1gJXKX7<{`9)eDAaEp%-_gO!*pf9C636~`p9h$25Jax9)cgEu0A6FjB zk7;5joX$yzS=q?2-AMs+TY%&1C2DVLaS;!I&S!&P9*E3;Cw8C)cxy20@Uz$U+mnZ2 zqQE0q{%lUyS=;8r&lTWjR0o&1>LiZAAn8AOZlIP5V<)u7P&0 zCpl<`i32rYz6mk=gc!C04f6Xy4fRXT-OM+8aXC4&)?a(Ezks;kC|IOjpoJ2gvazX2 zE=>{Ftrh?Y!(S^BUmt`cEWw zVD5Of6R7256cxq7H&Ooy-=yzj2>qAXzugRQ23!`FV|}psn0*J@!GIDKQuXeN8}$Rk zFq0C-e+`A&5o0G0@T|Z91Bmlgug=jvh|LVGioPP8QeHj^;N#iHzLNJh*x&>#VgY^Z zZnYqxlyjw3N&bZf+oxx^0kWE!F@RD(K8}vR^yZ;?cVAy$Z!efGi38g~`Mrh#7);8) zdH#!kBChc)aOp~2UESf~A(#SFWQA$m{xov~Pexu5Bm%N(&wru*=EdtAm*mDk?0Wsm zv*-ZCS7dpQ{)n%h_&?Al!JOulYx~0idF7?EhZ40b+yyLF^~^ zKR<@+&$*d%8h{HvyQ6-&nx9`jL4=b(|8f3bvQMvnVZNXL%j@Y64eLE|LyWqGh23(0 zPVU`C^7!3TG27~N+syj4VYjw#7U_8Z0Pq-j2N!=wUq4i@T?B2#UOZ5({Q&vk;DC*d zZDXprx%mY}HgG+@Si#mUl*Olx!6MtW`ac3;5P;Zs*NKPFL$2{b$dE1|@cvmKe1*%4 zzlG9+gey28z_g8PFVs3)RlhxQ;Nu@L#2;Irb_o_qRViVznbL?@E1N-CbgxeqDlb6bt}l8 zJaF?uGD3F`A{Ha~@d4^p;@5j@`LK4c}4I>oNP-cZFSy{;{qmt~5 z%t#5@d)~k6O;S4NbiR+@=kxvJ_xSaAoQHEd_wBylulaml&+EFL$B$Q8zZ@T|&w!Ta z;lqarjJhJL4O1d(nU^6S$z(}R27#*6(7-?yqHrIItA%twr`>1M$!5~-aD`XWf}!hB z==s}yh7K_^Y1i)E)pnVTEG#UkN9E*7JJ_o1PFTkrpUTv~??q(lKd_FeOkKotzjqTa zE`DlPF=$>KT63T4a}{?a5mA_j=X%~%!e{mMTXo`GciV4Hl^HX|&5F!MT1LiGJh+=h zWmL?6ykIhvbF@HsN*6GDp>IC?G+y8tY*u#f+J&dHZ_TxSZ=f~O*V14Kr_t_GgrFA!^gK^dZ&Ea9--h+3SVdz8)~#Fj<=tIMX~{!}8Yd%a9yR!5z*rH)=c0kylG4&F z!ovcQJtn{5R!vO}>~WxYd=1r=t`EhsSs&>oaj>}O1mySEC7K}fB=J?P16iz*^ z2K)W}{r!y6Kb$NID4Y8;O2(x@HiME)Vmzx04s144JQ^aXc;UB=KC^nK&YF;$S8n}& z9Q^hwkInjA-5)PgeEIx&%g3GR0}0X42>IIl8D!fxb=pWTey!Z6f7? zeoxSLD5gb5C*aBV6LBgc`MVQQ`}$@p4^$W?`ZY*qnTCT2(RS@Q)EaxnXAkt_gST}k zVZNdU1Gh{=rQ^p(pe4=g`+oQSc=aQuW4=-+Au$_tAX&7v;QS#C2a&8wLJm@^nHJZR z#_I{0RMRK%jh=!7m0+v0Kl^woP6iw5JhaB7mx~)1{S!y!_5r;$H&1L?_~RE({mW6Q zr+fU?@}6&khe(z6$k$-ci4Q^J9~Ls$b`fShof93M9jUzqH#})w$MWmPq!0zBgb9x| zQ0PK&*07j}<&h0ncbs~OJR*}$YTBG=`eVHGe;hdoc#9K1h)GKq5C_X^WC#H~=NfZg zUlW6F$E>wQMmP#hab)<`rF@cVn$!zjlt!zaaG4Dj7mGa9}Gus9Ap0D3hz_K zP(dte2*1F#mYdt;V8B>qRU;8{qZF9CEwZ?7BAeCG)lITFb_DYY-CAki=pQlQ$%O#j za{vVLZ7q;T?nDXz8wNN-OjN*@nLEAv+6~X@({Zi{^Sk620=vQ-M#5lsKv9*(`X(NKpr%pnq8ZXQ)+b3RmJA?m%un(ZfO7HOF&h$5CZvy^b5I&3_){!~ZYv z*Uhm&?5^8S%)T2W&Mt1xIHEw)R#C4I=Xx9;5|&|++9o{Vyx{<)YC zespcf(MP09Kce*MeY*Fpy)yF~JsgZlD2AO99!dbN5#BxOF7VoR#4+GVIJ zsh|)vj?iKHS4{tWgbFIhdV$KPmgn*kD(uS_YgNYF=C1pihq0fA$ERP*ShgjeB%*NY z+xy(9-|Kz5{CqwqoA&F(Wi%yEt<4xn_=IJf!y^B9@Jg<#a=5=Ex^_bd|*@Hl%) z?bof`K+yki>u%mVwc848TYNGMa`Vs9>}raOqNJf2kt+J|$Dq5J&3m;>cS?N%>+$#J zKi?YpZ7Ta7Nt_pyR#VHPl!>?Padn*rkEf^a>(c*PZu-Y8_Sa$;QNbc!dPn2L@7e!M z=ubqHD9CyKT~0m|Vi1Km)PDY3GWLI^5a*rrA-lCKA}%g2xL9v<&j(R)>94s)m1PtI zLjgA3(_rWH=#s74l+4EnJh{24Dccih;il_vU@#ypq}Rjg_jQ6o|F^_$v+L&83&wq4 zixIaGTKA?K{YNkV^#gtR@r#ddiJymE_vA(=XcPaJGpEwae~b#PneOK^E$Ll~I|T6i zu5*pqax&)DlrC|8QgGtx1f2FLJbck>IKNzTh(Lz|e*}AWrXoCFkv$lnLWlxLdhEa< zNl6usXQYWygcNn}-^;@jAli=-+WZUrzYSp`7<|^77sfA*s!V=e;_}u@w)T!}5B(L~DLwK)Uy?PbMW03VzdQQJP zaAD`Eg9+@|9wO(-rV-u+XZ4|$kb^feTj0D`Ra0XNlQ3!eO>f@5T@noowRjtM59#jY z%RfUe`P4sEdI6UKdT~6cmv{N`TV+IV@80Wvz#*C9j04%DiI;StE+ znHrb3@ZrKghabvE;{_^V8n$_r)u(IR(l*3{F9NcejI2gBSR6XaIFtgB6KCFP7%2`u zAai%uDcC7S)~h8Ms#)4q#%Q9719u05YKx^|MCR~mTbhQP77@ywp)O{cbK zZ@)d>5D`(TVcYPWyCN!l^j=HlPDyfGa-co`Sdu@0n@jBNA%K^kS*+68Rr(v9P8E_~ zzWr>(X)Aw6gK^b8wtp`C9|>LS_;S0}mIf1-)v^gjMk8)+?i#lD6Fy{joeDLn_h{#{ zEb^Wglm%SypXdbyekNogvAA2B65n6^?IHH9EXfagG^jgkG;=Q&YW^E*1E_Kk|1x`p z(E*R+iuU`C<^Id3UMBmA`*0*Q&SlMBur1{1%g>I{U6_d?p^YKfJXj*1ytSK zq8(s7aE>GW?A*Zhvj&9m!)7HF$FZ?7?EBj$MqI`DR1|_8^NYtB-v5TNO6VMXPu1|X zI2c7Z!s#h5Imw{tw^!Zc_YP;vgf$ATaTH2bq>Vll4Ga;EH%jR}w{T;_7vRg6FX_Wy z{35C2TfZd<-cL=nt%^U5qGVxi?lyLILz|Z{&!lI+A|4Fl1T5hv059=bk>E2{;LFJ( z@NTI4lMm~$k^w3(_--)EkS9HZ2T#3KGhN$kcu8W_cetY|1zwGLDE!I@DT1}Y4qHUy zF|u14txBO8&SM}Xx~?K56sw5<4Vfyv-Om>DdIZu;^CzTqjcvFBj@ zbvD``i~$&u9hH?`=>fYN{eb?FKL)0PH;Qy9orV|C*|p)3XlQ6)rT9Acb?dzk*)TcW zgYu}99~T`^i+GJsssJx5V>j8aBhPFBuvL)WgKh{7L@k{z%>j;bmZ1PoYgK**IPo1i z{`?(o|8nQ{#PaEJ#aebZ;>>p`B|MYSb?91-*7wml6YPeMndD0hwKy-Qg&|J>cpTTn zKPe>+xblpSyTI*IeQ{?|2OA$>&aWxK-?7J(fHnLCW=?+kN?k}tl8*EJI({2dz%F~a5>MM8@dZpE5w z)X&_`g2a+lhyQO|REi-e$Jl=jC*a}LT@hqA0K59v<{v(6!1;Xa0}RCe2_tppHW)Q* zPba~f7)}qII`8fP8KI`84jPYVL+#?_A6H8ArfnmPH88%y$LHtgf6*=)?J5=JXn%Z$ z&-~qyS?9{$3<)uuw5C062PdCIcg&B9i3BTA!JrweJ*@7k<<(s)9#edmYV zPoZjN)VF&9KPh@b#eEJ)5xSwf6XqB4I=e7TxX~%T$h2ZDUD}85?e^Q&t&| zuydYuUyzL)pC$oC5)p>^{+#erlDN7qRfT_`;cW!Wx3Ny{ zg-^6cx{y-rp5p_yuiV^{)(LHY#}OlZaPuE{kZxC$5BB+#rI6~qCL;>bx02Dv$Bk~V zkjxq-Ss1ED@^IZnQi;*ca4!D_O&&q2*XL;&=32<1Vcvcw;tzH`kEjdHt@fBt>oXaZPIWrmSRDvmSb^1nsJkUNq)^mzG zZH-Nwy=R%os>s8Ccy9eGYv-jaHuqH$S-Wr1C`OuK6#ErPp;4H=27j>=(I7J$`-R<2CJ-L(96JH97}aJ>0OOYXSF)m{6*}df0bnMDPyR z*45?OF<+VQZ7Y|%q0fmRoDV1^bYcxIZ5GWR+*bX}T09lrkzc~Ch&>*#?42VhYMC%z$? z=J94V11-b|nld>1nupH^Cb_L@{`9apx-$EiT04A!$@6i8S{<16i;l_~T+%!ZBpcLB z5aTpQkx`_gQOVM!OHu0rTZ5>ie!W8!NntHWSZLy|4U-9)$kUkE^{pjfaPgWYCF`jK z(1L()5~U6T7mtV4-cZ%Fa%{LaYXFoYA*%%ICrH|S&qU=Ss9|GMOS3jBi9X%oZF(3_ z2-F|_2<2o1Z}rK=r@_;YU;)yq;fOa%?G_(=b zH$rLxVvkG!->vOTEkP^_E^=nu>Xua;>=nD8bf3mKi+|SM@^aInTqZeKXVVER|3N$I ztDupha9v(r4#b{RXar6$?L&)QLC+0#m~$A$|DrXb)96_sj)jaf1TQStGe5Qivx ze*cG(#pd#o8~a-IJi~2V2hZy1Ey{l+7iRt2l+u)FQKmdTBj#@s#0CNn_+_cMwS&Jv z67n=I?#!7p)z#IM6n;Ck#X$%`$tY_81?$WK&I;Ai?+^L}5X&C;n){4Z^=*6N<>Ny= zfM|6yU1?F#3eMn-i{fWZ%tcH0Do^p}B z0fGJr+xxW~WL}`zubNM0wSp`LR%@KKoRc$+;R!H|6OhS`TaOu8A^Y_)E@Uqe2plWz`fFxll$0;o<8W%YZBC zO7tiD{`wHQPpCL~wO`l8xf~0A`raWM$h^RJpr8N!d(&)L?RR3x&Vq_VT>$u2R8-VZ z;K0B@RaF&g`^v25pKmFN^TUQ#v=?q@2gfk9$jYBHohS zZQ%F?HQl3TjAd#>Bs7(=pbj-qeQ{3$ZDxDs01Di}I(ByBj4b>itpjKlLFWrA;G?>_ z&1g1RYO38;%paje3HIH$>a^_AU_qcOLPr?Sz>ID=oU?b|_G(o7vj>9Q>{sjl_w*F_HCm15KGAN65Vq4I@oIY_@lSeale5){>+ zHcvxX5$LDjoKY}Xe!QqhLa3iL0sB+#Iv0XFs9A_bFO1`Y!2FP0VLDS#-lwFH__p_P zO138=+T6Unyti*#2mA1(1U!}qmPbL+MMUE8vNiL%MR(L}^l?Pgl6(-(o%f=T#MW+o zJMCQ`P5P@3mtn2p5bN$(pt-4~4{TGh;Fdovwlz6SYjx@aFiXm;14xpx2UAh91SEwo z!oteBj24wUS5iFuwg`&f0zr@@M^Q$_dUJR#vhQ)$rF=hboU8oba4_}&2o4TlQG5c8 zo=D? zv>S#R=AZ>d>g*W!^j5Ap&CO-cdEBJXb>uTd5hZ9lyJX1{oGfvm!)AAENnX3rT-Iz> zG3ta^k3;h83WxYgO!;kkw3&f*`0|(6Frz7^El20fs-vICUi;HAi_rmSDhzw~6{gxC zyHPS7);oKab7Qd8+lxMP$-IC`9a9P4sZJ&V7S!Uo7X%iOoq*CuIdq5jveU7yp-XRH z;b|69!IJ2J;q8XDT-*xWXRA(%iTB=|hht@C%4Uv3V#;C;X>Oj{n8`?p0+9mYy|CO( zA580{S^0=O6@sN7dwOQJi9LMZv2(ozcu+$`H&^bI=g*1m0Yt+iFpkk6$P-|s z4YLL70D2@eVq3j^^X6-{fsv6$s%7^Y!Ax;CJfF>`Q4hlXxJ570sf zdvuk^znIr+?h3{;OF3UH{prMd0zpZ{YH?@cqKJ4dp3OV>0V$}TvcUt~V)yTFsG0y> z1pf6U{K?v}gPAHAMmNXhd!!V!D2t|pU zRmh_Uq&t+q0k@er8RhW&uAg6Zc{$Ztmup4e24g-VLC0yEIqlc`Jsf-S<{kpNLN$w8 z1?UBha14xtkv@L7Lzz`74ldH^=|WXe5fOeMkY%_mfbI`aP; zM{w+ig@&F&yn{IBiZ22RC`HjRHa|Bvd7$b{(%I^A{^U|BKfN<&RM4pC`?635Qyjnbian27p`kcxJ!L|9JTVCkk5x6G!Xhm@J?2kZmK!eQWEN%^`|kP+<21ItSisG zqdLs{eGR;+TJ(2ofSoaisjGI59I+ApX%D^FermO>!Q6Vf9iLxF&iTg$_2ejVGW?fc z_9On*K=n@%sQoB zylHZHFPdTGtTby4T)3V~3U{%uUlu(ZSJ+hQHQ_m;R>rxppz98so7A22A44&C|8hL6 z)R!O~7%y2MDdyj>Q)RUuF+L5UWMs!6TTF0T_i4d}J@d)D!JxQIMpnL^?1I9QO*geD zhdTO3b+&S8;^52tm*K?zWWqt4SRYUtmJ(zWHJa_(+xy?&=Szx}ZZXrJ&mH zex>8Kh3ZTGB?fzPbnRW|K8N?6lWT9(fYCy?+#T%KM1r&Y1PHy>sSt~Jz zVh_>8j(J);U!MB-g8cS^yipUHE0-?ys+@kH5#_qo)` z7?;nvuz3+#598z&b>dbgFLBdNZa5UVcum7c(Z<3g)b-uFj!Sd8zY5!&D5 z^@iQV5i(Fa{580PP31W{=Giog{fD<1Y~y+vGv2YD?|quVu|Zc#EhmcAmfOgP?|Jg9 zW0mEcnbmiJC@Mlh7*;tyo&Ut-je8Or#&RnCtJGvAE65}uGNr(63wD5V=+%v)#|^ZU z%&uM0PMmrzHs;=P@xQyBjpyVzPLJaB&l7z|egpbX5C_Qg3%>vS`@fq$8`j4ym^_q- z?e~<6h`4{dotNri)>zeI#Ikp)OdU;=vD+phDf%_JcyjdFh1Gs9EEY~4hyS#r$s%sb zip4G#|75{j{;4-j2c*={ z&^XM(C99-FO@xVS$r-jz4m&ZK_h0?ZWIiI|qRG>$d6xsrH0hD~u0$~@2k$QYGmf2W0+le?`+T*f- z5JaNTkd;F~P0a%t%kEDkYM+O>mfpZ6P0ztj+JL8+OnNChk4J7=4 zTetere{swB>a)>ksfEH%gE+yjB)V{Coc~&a;zs5~(Y2zxbCwE*beeZ%Q_48b0~ecF z$~WG)ArowfP`864&42aWWS!(b8hJJn6(xs;i3}IHu*sr)=NQ$a4L7; z9+3$NX2`JspSur4E5~m9mVpwqOrXGUktl^zJJIa7U@8q&L7Pgw+de+rTwG9e+M;2V zos7)4EYzY1aL2J=4<{!86jlSVHB=+&nlDo16%^1TVa@8**=>4#Y+UCEem~DSEfBml zZBVi(mT54*PqZ<3NZ&wpnU(AMsJ2ylo#ZQ1iPiR)fXgFZ~_=O(HzkyAV3XZN?l!`WhI6a zSOF2KCCd2otV|^2S9MlFNA5G`Yl4)2KiQ~0y{Y1%jHVM-Kn#MOEiHFY{tBmAv+oX+ zarG=A8F?rf?Ao;p%`IEo+D6;=#@nF=5mWo&15F~>I>AAi?II=!|7CZpYI@}`9W~=G zzPnpDQwCe@k(T#F@<_X}FZ=%L?}_&)7ufeYYssT$-5GngyGiXBBT8IU?(LAg=|`z1 zpT~b{$^a-sGgs(gDceyWbhWEOnlrr=)z0T`x&NWO{Pq-PgQ(Le*41*{-S~RZyTZaw z0Tw!Dg^77eQu|(&KDx#a3FG#s9x>Mkba$hOhWt8{_ zS~g+jczc}I)4SrUxoMAXzVEHa^B#5dpzkQCPvAZo)ILwBCl7g4yTXt<5v@(vazZ1q z@Ysh93Q3^ww-(v9g!=ID^P^K_z(d;0o^{98_}zhYy1rveAbtC`ZQHsp&P9+ia|g_1 zT~l{}*IO?tKjr5qT(6jP#D#{3>p`N{{Ur%4L7}#d2oByfM(3V`CK*)i8sKW{5p^*@ z6{uwr2L=kHovAY6N4&noe9rT62@;8mjqT2&5Vrkzap8k;j3UKoJ(jN}LwavOr=#GR zil}j*gFB(VLp6;a$H+jnk&FOiES`9kFp?Z54h{|$mWcY&()WhSpp;am_ZIMrE(9Og zFs;9QpQ5=$S$jm(^X+@EVQA)}b=8CH2qgIz3d@XkEBHKUC&cI})Q;CeN+AuSy!P;q z`8T%79XkuCyR>5~F;T1PdkOp*{dSO<#l-9|APT1wrO=oXybcH})Uss3 zBl4waO(X!=BgO$e3eil+!#qkkRx8tSu`4xt;JPhgvF*|C4}bC`6=nnQRp&=@A{-@5 zYktS7J2=Y0)S+b6_ir ztXS1q#?5ruDyk!nm1`xTHScC#GJbfZ|F(+Z;`|-=x&>WSe3Mcg!rR}`{QafUbB}Q{ zK*fb6HudZPHiFQ35zw=`eA-z7C!W*2CHxay$y&7@8H)|uBrN^2%+l17CGf&^y6W50$SF^6cJv_#0Ru2QKJ z?js2(t%*>jolrg6f4C#=Rz=3Mv%U8Rs|x&A&f-}R@zW)DGyMVi`CYGrddHcAd+xgW z!PW(>#!Y`c(O+yue6i#Bw~_IM+CeG|Wq01Fk`6aG>qb|mHt1%IX1qSGvS3!ix>j(t zADOt5WvFXxp~7b&72eLI46B5eD>1+RX0qTSoB)*yYMUDGu&G{X=^LeZC9j|p>i7H@y_w{INKJ_i;iyGP{c>&J+O8PX8Bp(M z5U|`a9_r2I{E(!ZAZ$1T+NpT85;(y3U>m^F%&I4VC1jDivVtqczL9(&r$evMceov( zl$aOj{m@E!gX9j-2>`_sEk22!>;>JI=FuwxlOcq6WpWOQ1-2d+*LB@N4_G2hwOIHA zq(d5T_HQ8B4Z988q{L`}yFjD?N(O0++o6~HvOsA47hWgU`&?oWw3UQPiuU3>99Ct& z^9mqRKlH57}h5;Op61~g(S4pc9 zHO4gL4HGRv6D?rQES0qbjK1)j=y$x*K0R)M^U#mfcPa~+$r6Yij?}kn6)k<*;jJph z2)`VLeH@0-+^;$$$lLePzyYFbCiHk&f>7%ijiHnGH3oBhZ#d6|gNSqe0R1p}ij^Db zHYzQ387Ge-LCX?QZMkbV*!Z3MIoZfip=kleWsaA>3p>_KINmX_8Ngi&1aAxp7l zref$+FuLqI{d{~XN=jB4A-=*jfzPQME)AM6C5;b;S&s$5zXX|2uT5tfHTmdE^iz8= zk0QjOME(@hMu%={hgf*!pw>kAkxdYm0wHpQSdCMxAEhQ)c?` zT9mGc1Blw!kj%jY59=8aTs?}8_DpPRZG9*muud4`qFro`F31^XE41vInws91mk$W` z;43y%U>Af>#(*dSR{RNm=Llxd7B#OwYV^e&?xPnZ86N#yNvZqOL9?u{jA0K7vO?7u)zGmyTBDG~qw1Lk=6okU0{D{VVA#&ai_)aify1bAA zb`_QsvrxMeZnfRp`gj{e^arX@7)N^7J}5qq z%5M=+c+uT;LBQqt_GrL%2NfCYGG=;J1lWlv(^hmYK-TzEA&}M)<)nzGPj}V%bDcf@ z)#~xy9PWoQLH2N!m?P@D-Ee$l=#wXUmDqpDG9O&AU;Tt3ZiCO?c?BnrzvW+i+F;i5d zsX4g%4A^J$CI#ucA)q~c_oK&S1-0K+NevF8TEMn9N4#E1#$ei?~NNK?Fkiz)roB5H<%V|hcL?ztA+l-I1bZp zbW@h$OG--{XFw-yQ3obz|Ft8+Cm0}pW;%SQsH$q@gU-@r-3yoeq14o%wT(-5^!2Bu zq)#N(?y*;D8Kf8CAwS^%D^~tpthmbiYxh0=Uhul`H-&yOjS#xXI^> z<7Dg(a zMg`#Kazeu26u}FUrgGmem~9q{Z$ZiJ4r>g;-`Yg&QcPA1U3@g3YTt`LeFYP41dRx{ zw~{{e-)#$W)jvG+Nyb-LU3z+}egDbBpe1UXAsQybVcnKbioL&m@Wb2ihkMIMblL0s zY9?+lFQ1L@zZj-UVYC?k`YDR7li_(oMaJ7lkKaA9Wg3$;`VE|1l+FIFogzRe?Q0}A z>9zCYt8Wv03~d$DW-9;G^IQ0#!qMa>r<=002xG?xf>{{hVn+V}2o=AR+WV$s;ewiZ*PK0_Lr z3E8GdBQ*Q}8$Nn+8Yuq{rKNj!X5W}(SLxk;Q`BAE_+S6-{{)v_K3z4|S^7}bn`nKF z>mncUP0qxalt=&CIU6D&n4&lj_0#h0w96YGm996LxWqL->s(@zgoABkG+`@NNj|TE ze;L{Br~k;cxlYt`Ufg?F14~~3n1!z+k&**MBRBvVdDIUovw7>{J6mAaik_ubpaP90 z7o^S0im1%~C?fzw7J})_6^<7dWf7k<(Lev6*d>jV)ALv0c{(~egocEpk=5`DjVrCI zf{YkO0@J~-hzWc0#NgS&){U%O=Mbwx4Xh{j!e&~^i@0aAGW;_Z$ zjrqm_dt_Ey)B0-i*t*Plkr9Y}H_1kriFXM$!iifVH8Y9Xve(-zaGguv-L<7k9r!OR z6$=N4k%55#ZWZXfAB+>Wyra|{>b9V?#q2f^Mt|wFpelMo4l#6k8tM(Qy&|kech9KS zkw}ODk{_<3WKRJ0oc#s1cWV;~?C;Ay;HjBA8?t}!@S^L5#OTq27y1KuClda=8l7kt z-evmktc;X^`rlZeY}-tSy zWWAmXScMbgqrp1QG8x^aKmfZSdq{wB+$7YO{?YT*XA2={Ga6jgSq&NmRz&KXUoW0) zAig^;Amjjy3lf!hz9f6{<~`L_Ro>+8Z&^&u&7IplJUn6of`aNtA4#c6KOn+6q5d)< zKgs?CSe+5Evo_&V@~pub0h>HCAq~OS1%Uu#P#oR8WYN5V?Qh_Vn1rS=Z}!V?3$9-g ziU&FTo1V3&d6)7ev zGN85!MX2Wln|^$8F+>|d&I29f53suHPZN2(=)mjLiIKJ`<0BMo=nfaH@@p{H90jRE zHZDEgeu{tgTlT>mv4xFTmT_XHWf2Vn!t4OcFD_Z$#z zkpl{YAxAKtt4NjFo=)TL)EopGl`uXWfKi*Za00bdtSk6;puF`ff&@Iozn&M5JISXL z?L|NFYYbMfcW@E}a!X|0eLSbF%Le;?Q0#7u5Z&c}L8H5UA8lO!D;k}B5^5mLQDivF z=-{C797Vw?2)fHZpV8UoPbE!I4 z+>D5fgoq|OwdYV{!4x?z<=(v$(@?TKL?vt1tii#KbJ9=9ZiI+d!Ii9&Y2Pnr&?~voR25c({0XtN(D%3300O~^PJH%-hJrby{ps;3*qC~3-GU;bT zsw#Q~#!h!1H;;?+bl(A+nN487o)FZcwU^nsRysyt3x8>Db}m%&h%zu$zEWz2?4yHe!1lKt^;lLJBM-# zI1-PXrmns=(6p3FCJa$=+hN&>m?|R4<4F1TbgF1N@WpQy!A^G3OA65ye?34|>Kq9l zCuME_$@2`!gp_;Z*E8$4(8p==ZY_~YMu`@TyV1s88Ufh8#43M7Yx=f)Zt4SAOb(;f zfb>rr85!y6-62M}WTe0CKB3!%xn)S7=ySO<}v;04zz?a}mN3HQbro z34T_E?BStN!tfNQ~T*-dn-bl>+G0Q z^woeJ?)ZrqVv$_R{HD4U1`VOuB1`ckjoVoW@Lo~7iNk*NX!og!Q_%?TM z6&|ihZE#@VZ=E&0)7;4^Hr@Ue;*Sv#zSyePFg|fvl3U0x!Cp=N^u{yeyMYSBjiy7W zylOkoF9#UeraM4PcdXlgi)mMdFah!Bj_9V3l|-ux)NGw4#SOklytI`7!;=dYb>ySi zDv$IFlS~F<%N2M2da_J{;1`NdRdfn(tvtdxT9IMhZ5%vp`T6}DTow5_-h8>h^jYYz z&*&iCnX~<11%**>fRX`xZ z2{}E;?l{JvE`|66@fGT&wXi@2y&@`OVg&x+o4UiEb;wXJy^@%9Dx=GZ<0PJW(9sWp z0T5;aAptSv{QmtpF#o;l<1=_G+ICA_u6-J^3A|bRs};|))YLHq1no%_*O>z?Hxib; zGjxmo>Ii$8AYljlO7y14XC8j|8mA;dBUs_M;z3!267%>vqXF86Y;E{-W_<#p?PS=g97~bIDH{mPZe6ZVx#f_`75UHKVb%#lMz-2=3BU5d*Q=#ZK~N0-rm8vu%@m zC{_)e4C6Khl)d(3&;WQA@9^R2DNRyijyu6}K)8m{dO9dq{Ry51i-pft#%wSsytDJu z4T|oth9JI*{g&O8|rEPZ(b=CkR1|Wd6Ka32K z5EJE2j}MtKe&>IZz33n{1IGt6*b+_P%$g+FLuQ$4Yin1M+aQz@^!Q1#@`ah9@uHj5 zdO_j`M)V%~@6nen=I>|5MyfW55ZyJ*HE_wC!~Fs9Yl*FPHY41^GbKd6!E>gb4PykcQo zRvE#_&7D*apnW5?Z0J>c(VN>di+9SBCNXk43CbJBDd~1=568O7*9t{!4RwfmNzr&r zu$c6^E{#k{A#mxZ`VDgzPb}E?achxr@lD1m{pyBF{bsSytvTAUBBAl6dGdvp+b#$7 znpxj`oFJa;ufK8)-F2pmOB(m?OO=1^Of{S;az-Y3!D`tR!ToZN<}czNbr4R}web6v zFnDP0h7B3@-!#jQ+o;v`r*yGhMQvd5wn#md66)MlWil)3fAoOypavJuXE zz(6d%RThUUhCYag_oDTvypj^LQkICaV0(P&y1)(FGjr{LahJmy5c(tz^;90|q7tR7 z&zU_asoaPFFXS2+EssB3b2~aP>Q?XC!oQ4;FJ7~^ghIxUW{;lN!e8BiF04lF z!(fnqGy+(K#O75nw}Y{79$Y3JYRxLPB-NW&pVb7FmJ$v)LYkS5F#=#;pv)HeureFc zryfAg?O=C|(GIY(vr8Lx0z_8E0V=0oXg@yU#0D3WZwDCcZl(sXM(SG*fnAh&8!xFi zQynfFjJ_7_Na)8t;4e zmRvT!U{$E|&Ri>vo%}oPT!^%$ER~V{FTG(g{yWi7Y6FxO*BY)Cy^?;L0Pu^z4~8gya$5w>rfldIjepq^KB5 z94ath?md{Es~~#+bEJA2-=5RZTO2sUfxw)75nZfFvvem;hYHHcaBbx0V)Z)|tKS{P zht!Wt(+eNW-g#V4MKmfTq=~T&~1~18zCF^`mvp2o?Q!BpDo`l-sc3xsXBPPvu*i?OF|LmsGg#9}x1;HW=np@^!$Q(4Tc2i%P z)P6)QBDR^gX|U3lKYqMMWXJiYH|RZUSivF-kJvNb>-;;~KH9;aMv8D#h|h;)0`VDCA;YnKE3~_~~6dL+7nU>2kU$ zKBA)PdTY_m{EgR;OeaJ_F7u`Ux zJsXdVJx#mYeAOzDYAaW9xlOZi{rl_J)G68D8@fxaf3z@CN|@(B>T@wsq74QlAt#sr z9Hj0IhA_%23wbX}Yt?mePFj?1QhPf6al@5s*3Yd_Flc-;aDocaL;b`+(v~wnzn5&? z{@BGA*8c@e_z*7fz$7>OK!D}FR8z8&o{D@uk1;fWLIC2*k|0lEE^nMaIYR=*;Ac8iu%>bK> zL;0FzFR_>R@+&Rw4)PtMIPQP%l4M+)J>p*ajma~f#InVBfQo|_nMMljq)(4{SKfI=9?o^^)%Is{bdu|Qs% z^xtnFI_Ur#OQJW0XI!0Wsnnyatbru*B};KI7RCiJe-pOu`wDahgwDG2d2G5tcIH`3 z=xq?Bf($-NEd|ofiBAlqA%Zwq`R!`OHNM0;j%p|~Q6wOu?4r~&(P>MW1>rTJPyoR(6Tmz5DQHz6@=e9dc?6E5NGzyQgedYU!cv8S7DXboYC)3n7~ zl;u-^j;qY8(Q76J;o7^^+kn{lgLBeaiJi-A#Pwydp8kSk-IG0zwAXFF!bXo`5kK1Vz%L$YQ4$;#^a-J&BIINs zLpC|IO`w-cIT$%vBDf@Qn4T|088{5l9*ug6Yc{yu*N+Hmc*agULka?*<}io5(jRNoe? zI))8BeB1Ac!NyMs51xP89W?w7G-piGs=K!~HTpl-lHc5!+#CoXji4#;x+wDKjugGM z3~Qm-pEiVr%hPS`USJcu^_r~{zpWv1h;E^!B6r59Q0&5+xF~pr}DcaVDEiJ zJHF_Pa}A<0qBPRf1J)>Ec%BKk4d9%o!)F$a#d(^NxD%DPva%iyX@NM-xx#Q`H#UBl zVler(ty?P*jh*LZbu4?|K$CPJfVp+>T??>2t2(P@v(7p}D+3}((a}vcd>`A-e$$>` z_S)&n#Bap~M-3k2Ce?r(Mq_cM)dQp^8Q`Kq4UUxP&jH`|+@W*Q0leG2FaNdtIrda3 z_p9~Ck2T6wK4P><(lJnY&l~J*I1og$^!6RJXG-K)K*W73PM= z#3DOqFG??c^w_asggKqi=#SC%^Y;=PnG1B}@h+TF#?(@JD!26xs73H5*@nAGon%;y zHcj17MA)Nn^~%$v^_}eX8sK-^!u)pue)bn-@!xIcic8|8YvDERD0erRv@)LNcjpaW zrCW})gb{3F7pJ%azBY6cG8sGYq|Wk+_02{{!Gh0_>LPQws~Yv_k*kKm#>bknb6l64 zH{p9Oo5Q3fq<8Mp?e2YVhUK{?kIYx*;yL$YHwm-~98;f=J-3yEgMr)7Ux57KQ!ph# zqKo{S;6xnM$xgaAJDZ>gHYwgu9jX|H{fZzNHVlrR39<~XD^yUW>^TuIBSR$g7$_1F z92o+YT^3y{Lps6J>P2`hwX0C@L3VCG5Em3m&ahJ}TV6WZ)-8?|a2a~^|ALp9YB zO_6HBqhL?#D+y-^om_Wm*8NMa9i+2IO_rz?*`dgJnK&lxtDlPTw;s<={w%71hv&x~ zkc2BQhFIO5Ao`5)gcbO2@kUHdD`43L%1dC{a^m7^U8U*5+MR4x;~<_BR;TZ8B2MRb zYG^qfUmt{VG@<2G<4_LMgLPDEyuul| z!-8q?(O)i8G3(JVIQq++>W!|hT&rWCE5Cjne`avOjnAJM*0{h8QLwK_^ba8n;)YZU z#@Ale$tEbb3UV7%da2sM-N1wQ%bg7LLD3vWg<^Rg;TNG(kUR+L*G?3ZEIJAEYQ(6ggk}8ey@V0$gK*V> zFbBXt&%yVeCA-zRTPyj&z`@}m6NzwR(3#foD=uB0F_bf|)+e$SdpuV(F&sX!#t*xm zD7dy!tzPYqo;Ijzx-&H2x&GwQBbr?&-w**T>)A!ksYJJ{hKB7#fUqiPjGXR4qnr_E z0k(hst}%oaWiUgB)iz&quXohUx>k%{c46SW_4LG67&Tu_GZsmfD`<=lIrSw$$QS;fs>8_sOQx~}U%M}eoNK=Foue`#Eu`u#A z8Pf9a*zH<#UAWN)ie&!-j+SUX5J`5nH8OJT#fd2=Dn@69i6GtH?K%z}#W`8@8dW9} zq9mKod2LBX)KuTYo(r3W^`~ATrMU z=-|?6A$3s!ih;&mXDz!(C%=69$3G_V_DjCpra1O=YsS;D>;=6OM|u-U*H?f4WWBQkKxc8n9zyphoKN1=*- zyQCmNFV;2p6El(6Ij^_eI!$v~H@@IS!|lg=itqPS&^6x-8gJ5;Nlj3gnn9~0pp#5x z0p6(C*eqPe+jZoJJa=O5$M7CBju3kIR3KVwpu6{rI;r^f7$`8;kQ zu)j^;yxseU4a*LtIb=H|H=sEkSFD!oE!^EF-duEV7;IWnXW$PO@45GKJwXg=y6@|iBDy-TX}%+kfA?|TE9PGC2Yb5|ZZotf@W-TH@f0(26|Hu5b}58? zk^v1oBvmNnSy;J>!{Y)RCWbZ55E2Dy{W|+cq0xBLp358rgoglUqS%#VPQXVO=to<1 zQYS(7tg%D^9+VH8H(ndmo|}SpOc-2b-UgPn5N&*5)`x6p`0DNgqCZMW#-ZWYVeShy z`(&LK?!8+`JYk}vJMz;Q-03)$M_?Hx3-KJ%J-|f-=TwSwS1`uDfgon;Syfu0AtllM z@fFu*P^mJOL}03TaIZw88zn;gt=bX0=!F0P$Bd!$F|oUTz5KCbR;`c1oEaTgz(Fvg zF6mL()s@oqx{yXT4D|N)itgU^7n^qc<)Vkw@Ne%WM>q77QD3MUJo@*%bnk;WCMcv4}QK~%gAp-!@{ z6oQu!Ofq&DEXxB$w1PRh>M~*e;S^06JH!Z}E4)McBn3wk6dd%(0}vjaw8dXP*LzD# z1PGVQ^TGnmkKd*R;&`NzdHpjaB_LgUB3sg?^(LAbfqer82U6e<4b^Cy$Yc|-PF)XZ zTz?0PD$y^Ilao_(!!gbK4Kxon`d1+}=aWNZ#1|+w2Gn@mR_@-^rw^Utl=B~xc|ZH=tGBgaVy1BirLGYAzk4!ah# zZcvb2h@j6M-m(Pji)d~<}l4YKb6Ha9E^PXmJ zR*ll6MV;=7zIpJ2o8#$?7Fxj_;k-Fa1c>*Yd50TjhIT;Q5ATl7Z_q1yki*2}8ML`^ zEn9r@25u}=tRn^iz0}%oV8mFPf!S0 z{JCjM33N6f2)$l>d_@15s&d`4bK2+45Krx7;TPjfk(GUGuhC85(g@$U4Ae&&a@mmS z_53^12_PP@<9g93$n)G-q9h^)ZV zcp%g`0T!#kV^%HtY_Rw>Yb4xf^!3@%r?ii*q>yG!!+RmjFvuj>d^8k=t8M^kiV*;a zQ*;54kDBqz1(Q$AFL`s_tIf@JIp4DT=;-0WCEt4~=16B3A-s5k4rm27s6T$EY5om7 ziGv$~$oB|4vSjuWm^qIB8JFx41wFGX`$FEg&-j0Z96wQr6YQYw~6DQkM5; zR0z^mI~D+~2(i8oBnW6bmJT9l*UeOOEQ1Bv_$G5mO8q0@n1iG3K27eSu zbX|{T-N{alcGv@>J!1%TLp!#{$JHfij8;`-wx(M3{<@rJD&XJ4ovEy*+A9SpK#Eyg zqD80r2K}3;2BKLf?wiPCabIrO-iUA($x{JOQ?CS1?OCo4(vI2+J}tnnf4Eso9%PKF z#fBgHUq|T|Zy@7YqS0|T-kFK6`wgq=vo3f$X%S9q=ItODK8K)Nkv>+jZ1b=8kq5bb z;*wwgimV6$&KxhS@>(u5jRqjT%9ZO}fleTonZc_Y>mUA=^=4Uan-Y zv9*f;!h15*3hnpjFdc8`5~``-+nmEh-kx>7fVX*UASvVQGh5TX&g-quzM<({Juo?3 zM!D~Q{^ewQ6w#;1Bg=Pysj0#ilRyIoLay_-fNcd!4>!qT}AsaSVr3nb=t zHf*9zV^R&TrSjv6U;Y)f43w9%K5wGWeOPPL_JqB-2r?=V-#A?7O@ItFm7>j%p{9}o z5$%MpojS}j$048_oK1;?2KOt)Mp?Lun+9XFDA|O+sZ3yucGyWSb(D{F4Z$`hxLMvj z{|2Moo`!>PtP-TQWW<`x1e%zvDl2H58N z=uQPV=M3~G%l(KFC{DCJ{xOIkh~o{2v`_2@nrOf7N-^IiLukNK;46y2)B`rq8WTM} zI|=+U_Xc+=5^-LgSXciFw^k2e^@F;BRyviu>vy4p!qB+_n%t&Lv?m@JXeX59lest` z@T}piB422mUhzx43X=@Wbnecz%)pDy46@QKaW?!X$bOx_>^?*31Kh2TT=_J!m}AvB zPFnHjSkdShsAPF~l)fLL(0N5IseRx)fzIdZ3OKy4)<(fHS83yk?JSE>aohC%Zz^tj zKkf%G%(3%xKNE&(0G>28VcZ27XbnoNt8WuhDI%U?1&B`vRpbPkC{LiuU^|#np9bmt zE~1cPWc!I0F_vVMINf>h$H^IlY}@t`ZFi-(Yj5%i7&x@uiy^c`0;5)$696vjQmstT z;S4=g&JTRh3_z!3E8O#7wTb`57EYoQ6TtZC+;t+`IOY>8p&DTz3vxyI0 zBD$w}22>8YAx(HbVrhWYGaSq?zSZ7ePU))K$)Q1<#cgE~73c^2G^qh5v(oAtdEV}r+q{RD2kJ3 z8o4)>WqvpGK7>Peaz4IXJvsJ}tM#RULE$S~nlJ0gWSc#$Z5(SwyuGvMpd?ACecFC@ z9-2C{@5fGFSLc@{W1(U*Vc7c!CFoMQhlg9h*69)EAi>7+3|z-F@8ilKg1csvu1c}A z0r-hEq6?!$q&VF!y`p=Ff|+~>|CSMo3YRRoYFI7Ys3P`)15~-^d299;)Ndxi(6AGs z-!#l@uVMFgTbN^to@K$evw8IGtT{}+ws}%pxvqB;b*a0BwmN{m5cRmed%N~@K$XlZJ@Xht zDB`}K=k z8Kgjax{=)SpXTV&xxMW$xY@b&JwryHxW=dJMTMUB-?zT!rSi#Q?e4*LOQy(wm`gr< zy3bDhrPRqaW3?Q16XK5(6AxApWS+s=z;{XN3eh{D0zZ!8mJVg}Vl^Zc-X@z2@vwZJ z|4f)5y{+XQa`UN`cIaPu(|oMfw%eiR$jNhz zWKo7KaZ~N!;K6SBh8dF%B){juB2;2$r6sUxLI}g6RB^ajjZ&O&!Mg0X*qnCmTi!du z?2;h6EpkJH`?2)>-D%jWNx>4#1(zPea7lW;iT);mp%dn~Wb$(RTyySxaFbxqyS@qE z8?xlJ6Cd3Z-rUl5pS%VK4kQS0BOjT?LTve8E*;}WGSanY=8N?4SrtK!Shyu&v$U#d zYsGVu1ykT^N#K{{(RCB!PiygukcX6|mdv|ACqK+t z@aa?PX6}Xg!OyOWkWo(g1jxLbturvM zf(2t{Y@SZfCpA2(cqpiJz&OrA%)>ny56t#3gi?9$y3n~bwlNT_6mgGTJHF?mbcVDd z5fvlcG6uVYEE0%iN4$a1P-f*bVRrF{OCq%d>QH75AYI*gmnr&rE#@V3nz8jyn`&)4 znL2;~T!wZX^Efwd4WcLaHNWTC9(ydkry+mic6R+flrg7eWEgwCqr(qO=qc{g$XwL? z?9z@}XWCPk^S3O6bP~I0${SqTcs!((%jztOZc+n`48W`=J=~>NzS=)OWXb%H&db=J zavC{T^61s&0Lb8T=bDTSXMt-Ys~PhoGO`B}%OrGWlhI*pSkD%JgXKwQ^QS@=sRVK$ z?H6!8w%PfOL-n(t2ht}DlmxnNAD%qXWXUCJ8om%L7qrgu1ZCWhVj7meR_TZjb|Bb zz2Hdm0_@Szk%#SC&t?2qv{6Okd`}kh8N0OQIN5NuOfO;rm%vl1Eespid zZ4Ev}Jk-s!c~K~G(hwdBLUK`$9(}=}UJjN!gFDl2DT|ALxV=&s0KB5In()qAP&OIn z(@H+Tsh{_M1bo*|cE@-r(Svbdk$*w%<%h-3W^HRo`&f8qxxVM7&EId;W*5Hw3BW&{ zJPpR#0t^dQJ|mD3mv1O9p3~awyK7^k;)=-IuIv(?o5q1@ss-w0(s4l6<9b`^DJArc z=ld)^#g0R#I~oCxzs~g-#pS18eLl*({=s)q6{brJiAf)0Zi@!OJ<9(>3~&l zpRgN#rr*&ghKcmGl?5-OBF`>9V0cuQIcs2k-bkviB`mW*tAZ2E)Zq)W z00(=oBu()&;raYOj5a|M8ttO4`lep{5JaC9aQNi=Bj&rZ){iu4(Vvd*X=oW5sDbrx zMH3kYKORL(O__$XM|{5g5y1cDKj>~Z>*wDpl+;mx!G&Skj;Ma25C2e^ADIChe(!N0 zzfz-3)hL%}%+uq?HTlA|9@IIuONaH`c>KDaueIU)-h9=H`svHz)`#+O#1?t%v$0LY zudn*<4*i&lpUX1q&(Q4r=I~uO|3pdoBFg+i6@>Q*y+(0-edA|6_Y>ttN521jy8Ejr4?fEbwK6yNb<0TB;xLYN9$;_wKV{T8D^HncsjDHDP*+r}TairB2b!W@a~ zP~~j!CI43r*3|cX{KZ~vdP0Ex|KY{%{Qomk@DV%4_w0Kud zdRIlWV@+tQ*KqR;dbtJ8!UvY+Pq=sFkKv0oo)@N$;_5Q{KF!}}WQBIpLpym6$W3Ta zcu&%6Oo5{-{9;Q)6+VjtHa2NKFW6b*0QBG-BOdh!#OrE5xVeb3kwD&cah<`iGF(po z!Te0WDsOo2saAF3mLlZ=VpqW)z5;jpC1bOsspb+_rRQph+R?FtPjTGIktkRf+oqy% z8qR$rwY&eKP7J&OkVFUCwTk=YH@evZF2$YjZ8_B!DQq(zRQIZ-l5*|22UyocCE*eg zMKNHG(^679fjawn8gCfrF`jp&?e#1YYs6^?>>FCkH|p8Zm%k=URu&rbEXrDtwb%g6 z*$42-ds9M#5OglYHswSD{St&9$-G`^kkf(dfGVlTWZ1Tnc*GQv3Fxq#MWSlgepVKHW4%LD4I@c;(CYlVc8&<`_ED=Vv=>M6$xrzK(%y+RZAYY4e;pUZko=uVf$;p5j4-R|j_Z5`f*V|~=P_cDc6HNw1$Ey`~$T`BB7(pgN@JHlc6?g9sO+%+K$kaLrDUNEL%_J1zY@6n?b z=R$0a{pQwcU1{2qzI?HB%6#(amEBmOGPYK%QUCSkFd`O0#{S~*_k7p}pHFqFP1xP! z$3XiX&95f%%ax0+RW|^0SsHdL`RcUk$sOPo12-%OA@);A*iUr(96CPop?kvfj!LDKi(JvnpuFdqLvoxC?8g-{@a zcWIXH44On??bSqi*Fi{c;B z+wHg?UO=GsBz|JHGWa~ub4hVPM#_Un%d=oY89_O}n)ftr;0`%gQC^{L+?*m{@{vd3 z2f19!ZFU`A4~GLFYTXuTxT69%!r?$12R<^yTwE9atwhatZ<{c^r2`;JzbRh*(t3^H zj*-OP03puCp*CT{Ehv}7_pkV#WhkS&AnE%Dtw5Ow^ap{MsKMXyjwt%qH@t1cR+x`y zM!fX)gK?f)U7}{Uw=HF!^Zolq^uL_EIY+`()vkkj-JnqvqSac~STJ8Mdd0CVah>ir zt?#oJTY^SPdIKWs#btyA6}$*aQ?pc=L2J@ndq?b$gY&Z+5mHV&W78NY1N}dr9?NIQ zbh&(YN|!ju5j2?8YS!qOxhtEab*CRc-mg(uw^;Sb9GNrWqZ9uzN(g8;zhb<}-XA|g z{nu1t(%T;~ou0Ffzjq|xRn+|CLrhI5rSX_k%2X6J=UfzWiJeKBty`l)g=?BlKA6MG z%IbBx<4(h?LE1kaeXdW!7u(XYh`AXy^<@hMGznU02HCK_3)&V^cs8+DMI%$)uUpb{Dp75iuDxFkM z1rz~c>e9a?SQ>U9eZqgF{Bikw3^RWDr7HAi2G7sibu|X=ZqCUIN$or~p&add#(W5; zC@_}qnTjDZ`6aJPALN60rJQe+XY!}@hFiA!e|xcy|8Hu0%irv1!dWLZ50pzq-ujno zMm6={jTWqAMDlB|Ao?AWLG8{vZ7Eu2LFYNU;0AVV^YkxTTPS%!qrHZV+n;+4(HL;Q z7axcqwspn~6UkFhTog~$hQ3{DI>MV_Dd zTVFk}!q>;vmLi;hs!x`N#N8006*McK#ik@nx%$F>jySdwSRSxL5sF+KUcD){3)x_Xbi4z?#0BQmE>k(4*B(&%>J{(1Gke2~?g00~Eby}G0GiQX-` z`tBi;$$@bkN*YN8qw~O-c8y1A?8H2N{3be3J}pjI2s6mPz|5O;4dDI$;m6tO-9(pv zsy3@yZZ3LL{1ye6^a%~^fMX$qn#IwVeUFTHFAh6@`0ZU?&3-7vR^%ZdC6I668&?kt z!k=`lM-Tq|+}*w#Rw$wq6B}y`*}qaZY{+gZws_Cja1j0PFL%+7yh?Su6XZ5CCT^Ja z!~d*UgBj5oAc#t2B*l<41Q2k@YO|>~>g%;0dLMxhGPRtLmM((A1O+hu_w&Gp7*k?B zj&ax+yVT3L;rXWrfEU{V6LPD=1GJ4o%M2!Dv*ZyEaSn%EbZ}*5Mh#>_8s+id|DDA$I}$zkd3`5>*!wOt8C<|6ho4<8t_h|^Z^Rk!I{amp! zNyM_6gZs|-65hEe6#SC@B$3A!C1~G*2O)mCP+E&-lw;GTCZLi?L)ylo(7LzYU)Y*v zPA1U?1}36QEt0*@DXo4M%Sz~bhQxk*Mt$_9J$M!{mWX8OMtj>=17(a>hz2#Z*YEIJ~~WKPWIN8|Xa7731ydQnzT_^W+Zng-~}F z1jqu%tUa%FD&_3r%9&VE;FlR7L%_yDc>08k#~~jZemRZ(pP$c>^JP4B|3L#YoJ^mn zjn#s+D+cP8WQrN0;p7k&c$)O5AZK6GjvUoD<{jbF=j_MF^A z9R$&j*7yb5`JPz(y|WV^97q}&Kx+$mFd0qhX7R2KH^4;Gegk(qoJLF0^1^(vg1CgE zjNR`yUTxcyU`Jw-SFdW54tdtVdgeE#=%6D;iZS=;-QX9_|Mk{3Ed49wlHeV%#O#gE z039Zzj|`$YKXoeN^4I+y^C#~aJFEV(XFTbqcT?SpYt|OEjFM9`$_WjiOw4~Ja4rXZ zifZzUs~SypP-~~M!GI89TS5X2mTqVef7v>AO~oP|J;Ij2^yinKo=e!mqY}g4_PiQL zi@~qiBkANYg0YeUJpPGo&%j8S;Yi4-&%ITzB-S65(NsP9&V60ZqQvl3PHwy(hPn2f zpPkZVQ}JM>uHB*iI!;!*-}idWi*ifb>Ql0u>$=>Xr42Ju3#lciUvzGnIeTD6$)Zc% zpTudT+N`-;??pa(QjvY-t+YnAk#Ux{07p&t%@`w3UC&N_uiBySrk=fi)q<__=!utX zAUrXK>84XU;n6U%XJWI*dl9ZBgWq_PjJoqBF?otrE1bi#RFk9h3&OPZw~sxUTy;M; z1#3$pJ?rl7COI46fqqkBD?JkZT>DbxOj#Kjy{Z2RoScd55dVtA0%WF$Mtf+VsQjEf z&vTdl!2{!~@H!~bWAAuIR27lUnwrDJf0N|&nJ=3YwODgQsZ8{1T;kSU<0O=2dPbl( z2L&pCkOS=a@ehk?l!ohvFz6M6uhOAInfN+xvkDJ2DyV0v*q<(F0R^Vz`r&*ynO+Q+2a;n02$`QyxSfcz&N%n?C*mbV$+3|;fQm;K zEOETpPs;X$f|L@9^G+Y0DgVohbpW1JlusCCbBq13zy1N0lTwSAfx!M+WfO1Cz;1YQwT&q{dS1kAl|oMu9${s+-pr*oCvWR&nr79u zmIaV(^}_j%iVq6l4+Jkt8H-8DLN`|s_wZ^bP;6STVZC^K&c0EOr0K|t`lTx&L*7T6(rn)WGqL5%Th5!=y@N%4pusVx^TpkpMl$F)jtVAt}Pdw(&6<@ zM@I+RD5vzT)hRP=GOfyHP}7CW(Q)2U3bEDu(6Vi~Ok$~pVodkRuG2=U`L>xp-_D=f zUJ2F^b-_I0z=0MpqZlK#l7@Qf-lFufDFao>Drj#QB{$l~n<*01{rb9KGJa^{gf7@z zy;`ZSW~C-Ct?h!!oG*PV?{I!Gi{xi#uivnwT6;C``yAJ>wCs0%3N7+MD~IRg3H0&K z;#Ft6WTiaN)Ua^m-dbBVsn}!1UptAn3!g^u_P^hQ1^-a3*a&wvaCqeaMThgj`Vt#H zD9n$1eSHU%z-Rz`2xNVWA2HNU*#b-Z#8A$dCV+gjC4i%7N?_1&LpD7%vDvGG9}88f z7hThw60qdwlth&o$ab9qN48MFV%XHNlV`_{eQ~*sIT<=L%gFWhJqJ#^B*@u#f(i?l z<@Md02QGaa;BN^jkR zmS0ePUsR=X-gI+sV8MbB%X*eU_v_2KLG3x=;NSPQYnZEM+Ln2l!G$7_{*y(`FfQVv z9048saq_Mp90W18MIa8?8(>KZ;ef9@|BZMABn}_E9}4lTH7z2kO$GP!a%bo{k5p{B7I6sqaJ!%0rZIj%N(!2WR0u)#2pe-~bLZF^O4Zi>jBmKNx-~O(ZTL zJsYDKMnMahaC0ieUqPq^O~prv1c!`ajge=kKqxnF4cn*4%&YS_ofgKSK4rlR4ah^I z=hti&mb(S&z97hcz?a6A%~kno(qx@!)r{ImH44_3DZ!5~8GLJR?M&2Ze2#HVkETKr zy_+jVJ7PX~rBIM~H3hzWc`I>l!U6+gL%Cn45V)_3*SXcSDVu38Gv%yYxVWv)IX#t4 zKE!spCfVk6Bl{@ZT>5@?30Hp_os*ka8>`w_pJ#a>$b^f5#kp`tw2#kT>v0_)<)XwB zyx?sXWQRoMSxS^P{luLCA?}qU;FDwPJ>UhpE_YKF&Km$H(Ce6kIOdy8I-<7km{}2P zOc?5Xiz!S+_np!MQ%qvy!WfLzm6KfOA7s{isnXw`iKnLznNHMjz`6`;Nw6w|2d0%t zxKVbkf&w5I^pNZi8XdllxkPf0WMaqtoU1TzBK9;bJ3e z^!AN8S4=a{oxK}aC^DbtZVHV(uk7H16%J z{|hK#Ij}{7@fW-#(5Wwe7!C&_C591Ftr4)~z2N0W!D(?4Dc&bMR@O>CFBECjoaa~? z3~IXur|i3be&f^zpB{DJH$}gj4R4+^&m?Y&Y%q#zc~mhBZdnHaH3MK+WJ!U|hdN{? z&nDquzrb!cM(4{+Z%%BzaQcD{wQ!q(jw=6HX!9hB%1cmG{Femkk9|F~0b_x^lgTNR zobsAocYmpmq+)bx@VuiALC`|!CN#_il0x5#vL)hUM1_YxE*(N{3>Q?DUtP*@Dw4kX1PdK5k@d!gg;c?R zZc;2VE``{hvXi_)Y2V};od_ORW+#rqof{=h2^qp7_#$xA)Q|TYSSMd>>S0hPWr9paekaHAc!7p zfu`##fW-B547MWgHGx6FF)+L=Cx;787kF8y9wC7KxcaZ38Mk`;GlS&`ZvWwyK*;%V zzC(Q%3wtbL;7nlbcdJ%M!dqtP#^fLeMFWVF1fV@#PfJb5DHnY{lTP3t;?e(`+<-hW zFOS!4o`_U@k?~rB9mI=EDH(N@4_RKoe?iruTe=i{-%HRVNYXKcK-*GMg0V#Z^UUP^ zh2ln1Cr$u5C##=j8r31`-;GB`{U^Iz$dIY1za6j?30k71pDQUHaq<4uaj0cAR@y8x2ep$@Q0sKu7CA!pv6E`pqpkxag^4p)9ak! zS1|F{^ff_RIxRn{i_>e_of%kBb%Vo9p06{ra2>3_ACUwyz}sMmtFZ7#HSf7x)7cjK+d3AEzFN({3jpQ5m@#wpN9k{6ZA~9GO6Wz(#W$ut{WqNy}!%G z?(gFVOYH6CJ8KQ?8N0LpS>VL3Z`PkDZ$oQGJ8o0`+kpk2;o|!f53IltF;MN~B?R*I z8w2EL%ousbbL{j}jKIf?uBUp1ht8xOUH#*|9bYV7y!HjpINUmV$F%Lq*M9rv04oA5 zPD-9SRp2N1j}Ru*CeTrDN-R206&dvoOWCQd$70oFH6h;5m&b?6<|gqkhP(ZX|{a1b$(7-G2aKfIXe#A=!A?@;WzA9L9sm!yF9PrF+|)2?r!r zc)Dc#?hxC5>)tg!E=)bDDmJ2US#$=kzgI0B!pfA&gkbMJo3^~I53PF4(jQ+5f9PNj zYCYXN{**79=~RVs3MP<5kT=VSIWK7QO|7!n9qt-9y@VsJrkmxy-k`um1FW%q<~nf9 zRv{AcT{aOv2gjj0`a7^vVCa9ELt!PQPT`_qCoDbikx;Lu^bob9NlT1hczAep^c6r_ z2<6`25e&6c1sax0e;6>4X;lWPW4&y=9G7jWLb8}lsB(r4tNE4l@;cIx4PX`wAp+FW z2`Kub^$$lC+&9tktr_X`cx z!Mt~UkY~$?!nPU)ZxSjnv&gBKQ+V^1zyx?CDuPh~w zcnLDkr^V6An8I^r{gJzYO7&TJaq}wQq-b85eJJtI z2ijXEF#1cu`U&-1LU>(dMyi2i8@5HZ7^X)?M8HT^1wD3dEe7!Oya+-}I(7uO#pc5!mVYVxPl`P1T?#0;!xdyw+z&vTYwRv77k<%>364XL z>YSOpW*i$!8hBpSx-rW0X88u%fL)iRKgGYi$E7~}4S z;5dA!wo|GG#N1nayIx99o`euHa}N^Qt;*IMa!z#|yBW1B7p_{)xLkKcbPvH2`jaJ)}+W>0XBE} zdq+)f3z+Db|Cxb;i;UF{c*FQUmzaC148Ye^PwS$TPR0)vv2 zf!2QbHxvr|0Gj4|SvC`E4)tufrkGs89HeRtZgcLu4VzFAFX2`4uLjhO4j=xw)a`BM zejQiuEh=_i_uCUn;}nDk7e6&=)19wVNNfOk1FKf&4W!(gmAZWsrtk=%QsUvaT%-8s z`n4KdY05cLBz_Y*GAv~%nfAvu76h?^3DC8*6McoWq9UuAAW(=lB@+`9N3RjATry>W zg99OeCuq5wE=@{rOlLTK>PAINh%DIDeUIUcwolQXVH-O_LX;QUKa1T1@5k1>r*TDf zuImJe=RCW8YX}o(LEO9tmTTn0wmDjO_1>42sL?*apb#~nd2&}Z%yE?lu;-So*2Xm5 zx{O#6V))CN@`k9bYYX3|v}FpH&eRi4{SSX)&6Wv3hdW0tU&FyuK`SW;67SPO5o=w3 z`L6@&2$vYk_L^#p7dmm|_c}swJ={~cfn8y1s$Pp6E|AKf1qL2rTSPXlE+z2b3kCG* zjyifl6~4oCxcE|#eEgMmz_kpCPrQtoorX$=kNKCQ%Pd#1OWcLvBS%)!|HUb-ZX&a> z`XeO+z_dvQs>{85OCVq^4Dro=(_1*tg}MY?TCOHKENdmPf~NOhD*(8CK7nnbsVv@n zGxrtN zfuPRYDp!)?L?H?KcT;-`Ue*&&1CUZgp`GcS9!v0Tc1J|7!a0ExYyW$v>r5yyEt`W( zYc_gM9PO8cT7{7uf^vy!|}k#H0+TkA;e5E6R+j3?&2Q7$RLb zWC`u3j$63kfTBM37K6qX+*T@K6zhw;n%~TTY64Z$O?;tSd8-?XidjMo6NzI2> znU1x!MM zFGE~eEx8#sipVu49{eZ(NQObbHilm?yG=)vPI4l^G2o|nn+;do{+Mht&C>JKbepb# zkK>z-$%L;OCPhYm7Fiq>*pf zTNijRvBW4**`&9^BvD*tidzSm$NVc(#j-%d-fAZap!oPfMd`Kk>?L2b!h#CQW&=IF z?K^hB)Ozi9HP8}Mwhj~Gr$vX8m+See&u0G^cv%bk?GnxkJ04_)bM=pa@$qV1G^cPk}+QtTdSBwP);St&37` zG?qzZxJp0B;GlV3lHuJx77{tdju`#9rrXT>2a@$)hzLj{qkub~orsJ8W7zk?+Xysx zF@HnZ-x|rf1LFWL&w3eIS&SKSN=w~sE-V)`jfNRRK!CX8-w=s@yProGDj;I*WOOM5 z=C2^AK0WX^6k%x1M4AO~x+;he%ps`h{CpQ6@c|Yik-xw5Aqr@E&&6e=X0wG@3xc-b z3GM`kHD)36!6;aJ0s-G>cT1!SU_ItFvLMeHx8mk+l8tOS;bLVU?A9T<2WjO9Y_S|? zvW_mOM@a-+QTB_D?n5DxQAWs;s9;7gjT;~@j9CCl?+zjm;$|3!V!K;!Xwi0^^}PpQ zZ{K->D$*is9k?8dfXbW6KeV@$mnZlhoMExrU0c%F0h*H;S~J;L-`!geG|V=Y@Yk|; zq^~suhZaSvZ!;#NuoHU1|B{UHCjPogfXGq&mef%(vT}0nbr+yEr@RjOi{Y4Webp@h z`~k4}HWRp(g>E|!4+`hmd=p=YYBvQp{Q(0N^gq6W0|G;pd!=N9!kqa8`MR!UnCrcH z^F}q!+gqtPTn(roSa@_CGLQfJ&HmA25iA&sg}6M$#G%xM@eJmUb*MG~Kz=6nptx^^ z2qQTCa(2=#^CDCny|A%;%)dzryc5&~U94G!*HP!d2Am5`_7PS$*vg2jh;7<@jwOM%xNZ;cK%=QOQQ7 z{VI4wmg1iJlOuH|U=`*7r{;bidzk+P@k#iT#d0;qka`zeai-e%kbx(@t&tXkApj?a zf6WLOoeODqPb{R!ODJo8@P4z<&4RbS#^plGU=UN3#&?TZ!`gjK(#X3V4EV*ZWHe)` z^5Uq-*gABF4I?$r$kq_Ga~XCKBmKo$Oi@F%?mPxbdR=8bjzVNmub20!%#jOcb)>QhhrH$?;_qN1`6HyasvZRrwO8r&@81SWEP zv|4-lh*4`P@9T-M(u!FW$-t|erKcV)i+aM15BB=0{YY(+LQ#&X$fV;unsFi+O=xpl zKfE0o*pp%BM$N6UytC?Qn#o9Qf^IE%ZNJ^wSEEqB|F2ZJtB0EgDrgTzeK!A=WT>tx zxC(Gm(yxxvvPsFa1+Qj}Zpn#Jh(-UnPLpFMmf>aA)H9 z2Y}j?O7PJ!+stFYz+TG4@126)IGJd8dOgjmiC?{w<8ska!AdHRZ6mK=0_Up8@)Al) z(uBA3zjjhA{i{k2rLtNC(0ICDpv~dvk}S2BvD~IE;p29xrhZqQ;r1m}ogfTbDq5vK zPm18UoioWSb`(Bz|B2$g(#|SI`X$&2%P)RNe8=NtGBVhpsLygkcfK(fB1tvl+uy+3 z2Li&lr2d-X4cuF_)2NrYExQpI58XUXjbGyqyawD`Q+ z;+MF>Gxw8&aq-EKP9EVD?n{c%+TO56A_Rq_mkhv}Sk}cp@531snmbH)U`}>P;3%Q` z1us8xy<9zwJur%4SM`AtPptEJ`AM%@L|&0m&q7V&kOWK&;8~?psn0@Y)@uv) zE_9$(DG62*?w$V8Nn?X{L{ZEw(zbjggT-|O_z z-o~6p=AEW}Qjq$<7N>w4K;kHIy^QYa3uT2FPRAtj`+c?lv^YR6E zs%2ohk#W-nx4TuWO#+Dm=u&rfCJkmLu}KiZ0b+f((AqlI$#8>2AQR3wByW{~-jBo8Hyg+wlc3jFM+h@DoO;#S7G1uqJGi+cqoN=O z+OoW}tks;SpWk>#FA0u7^F-1cBK=>yIQwv6f-h6xm8#I-+@bsmf{-=;ijaLRac{9| zKKzj0;zWHG1;t7Lilv>kz$Z8XKquu!wE)JgK`VJ|J1U2Mb){I(gOOp#RfauTY4qic zgR72FIWFhDTbUnMp)=nlGnhWPCb+>O%HY&g}~HeN-mIe-0nC*?bX4c;~^bK{`OkPD9k z#Q5xat|q3Gg&r7uKbb%)O0lMFgW62ezTXI7U*ZJm=Xlxf)kfSwRHNn~{{V|ePxzgPd<(^WrwaE&&r3nNU zPXWuKwMX3RbVXC&_q%(k)q8IHZnPrR9YmO(qRa<|a;A03`$@|ast#s@R8yf{5UkxRogS^~5Y$5Lp)p}r_-&WUF>8;#Wr>|O0<#^vgw{SKCg3C z1Vs(XYap~}dqvHc4pjp(XHXd0u-Qdvv*k}7;N>sZJ`cQa@4c@fbYW}q*YgVMt1c|C zR+t@!1WDR3L3J>Sw>f1jt#G9=>Ac8!xqFQEH}xv=*}Ldi@m!b8=6W;uxt3$MZp-j> z6hu4A#Eoca_rVPpF9J}@pZJ*W|7GfSbx^ta{{Wnp05@I2%-eYevxv2v)J$a-p#;oN z0GAxCsnsRO*Z8)xCw_os_Ut&4=W!x(7z#;RS;M&0agE zDzelG%i_m4o|GW8(j*lTB?#QeB+Nsxc9t1z5pW1aYV_(xtUP<{9nG@w0`}Z+ESzoI(n&>#dhn0NEn?7h=k%- z7{wdzhaa(_|9Vby!?61V^z0n^^mh1wu~pP&2Z-*Oy;n5@6-r7G++4i|xu06q8^0LX zlGL9cw3j-ig^Xwn2*Ov8yDh%|lmchxo<&*C%V~2>25`5gFAOg)dzfLSe;%U63zjyQ z7jc8m{-Dz6tiidNN^N;w!`pZ5RM!)-h|S1XIk&a-gZpNzeB6q0gf2!PkBlL*r>`7V zr+~=YTBE-R4SyB-Xr(zaJVb0rG>FdwB3rhZ8mHgN?we&2TPxg^fn3cvo&mxt?Bhy( zT~RzNJ$+J;z^KxPoL%E{ZJLyqLkX>BK`1$~|LxuWN%^>&ZxZ-dgvp zBj(Fkyekuvzjgs&8UCF3nO+nK*iV<^v7BG%p~uBS)=HmIcMl>oY^9*^QO}B(gkzXe z^DWy;Xq}`)Ee_{h(1{>@{=|tqFe>*t;lbzM6gNPWRTAbMi&GRzVwTmTe;Kz{KX9(w`wnZ~PLyEgo8Z z=be7Zj(VRh(Mk-fJP^tfWD0H@^Nx$3pwY$%DhwymNevV?c2Ahd8c z#+O?$)B$!|i%op^bL8&5zqSBZPrVCvDHu6W{=YVG-xZf&@4u>aX$$~Nu^Iyar~MG@ z;%JCwAJs;soVawU5!eWV-aEAeP-ME?zaJ41F=rwy^eV@1>^Q-HA$~R;1~DKsszbt4 z`18;~>n=#)Ae^~k{8zr=RqxFcF+U)hyUjxljEue$pT}xV6_r{i2|->6MsUbr?p~p( zp^?xbG2J$GTQo9pVNP?Gv#Z&l){3AzSiIk2(%g=pX@NsvDm5ozGT zZ>r0jNC+Hz)619J0Sr6E0jbSyNg5f(9K4sz`f+R!?S$fYzkfxlU`VI~Bae4wB&h&X z9Rc$qsO1OXHo#S*v0vVO{Qic4(9D%)egLHs8eyS zV2tdjwnBqv`*vcCS)(LR_+UxD#(s=Gtp)QZy+PdXJVXmd1Zv91c4b?v{{skE_m|7| z6NtE4`5=P0l#+-#L-b~syXtb6jBLLxn%eXmaq{Si6Je^ktH2Q){!Djql5+*=HqpPE z{ffzSI7Q%E5?*FC(XfLPXaa{OK-fgrL<))^TqSjgQa7HqRAf$S0&CO+Bl+Vjbnpw; zMxg^bW&MlxlHw@kBkne^ZEchzs!?HKx^N&ynkdQ?NKcA^Ki;=s{0kB| z2_HUFM?|w&^Z~&6(g^{hQ`=knK1Aemc9QoR`}Jzap8tD*lIW)+p%OG57Jpt$j6WCC z?Fii`ckHN8D_gea%S z%e4+N(=2DgRMgV&-fuDyKcU#E?4`e#XSnyXWWdtPU38S6zuMvy!GU7g91m>qulcQE zOJY~FT9SKL6iV6i#_!{9`f3kiWwJt+4zT^tUv<~w7n5b6oH-f$lD}<8mfTf!Y04@Y z{|Aq#-_`T2Z1n$pbEh$N?DA#U`;LV5cnkZ%H0Z>v`Fd|_{`S!xxhF2(+SoHoWO>32 z`S~=cj}Azie0_5QpnpS{mq3KM*#Y@-e2y<<4@{VFkdC@1dQ;;&dGbBV{CCeZsy>0!ph2G;9SBawDV&yWpU_j9(}#x z$d4k9EPnkx5tCVs_EMCaAM5+`#nRF~Pg*}KP933llTwU0oti64xwKR|4c7^y@##kH6nK?U_Gs-8h`w{7b|! zo+_1vc0|QNS(f86hhJCGt4%eV&XRg;H|0IYl;FSJY}Y65`*dOpQ~O^;ta2#7#yM-| zPt^I>mxQe+&A}fE{qZN|RS|%P@&7aaJNbm;e;)tcecz@Dt?%gHYSU%ZpItoRF;h`5 z*OgJ>48H#Ey%`sAfAcF)v@kO%(|OO!Odqm5eBY(b3oXng>-o3jw>*+n4T{!}oUJ0| zC2}*cLr>tV;U|VFT}YFxw15!93bzl#l1mc{)iG=L)zw`!?$S|0E2`=^>U6ZZX>O99 z7_C*_2b1ES3p#m0QA4L{9oBW;n$=0t8N4jaVMqcuiV}bM*w1GdGe=$d7ddV|>$C6T zx6Zr>YWeVH+Mf6R(7!OVJg&UV$npXXx?jljDdG39YXg;HZaoh^=OkN7fZ=hnFMmbGFHboIXW@jOMLH;7KW> zum>?)iugU?E2m`>8LidnBpz z0!H?NKSg!B*Kn6#Wg`i2_;QW<=@E~9$UZE#Ug8aamJ$@pM~}{cQNzToej`Y1odK$h z%4)mKiC5r)`8=r4liz5o=`M6*NZ+}|xT68y(DM)YI6e%}t;##@e^0FYVZw8|Om$V& zK4^dFG`*iagNp|R{W}n9w_neE_TJh1b_(5#gwWyBVge8!dh$7pxETd0AeF^CQpphW z*GgZws&JMgSmQy7&IaZW{v{nXnIx7K#BH>4%N4P$x}0Lan$N?c{5k=}L&3 z06_&YJ#)*3iI>nDt=J>c79+#jGOE`Ioy!m4w`G|&m+6vi(K7MRX=KAVTvSa;Lzf-M zr59$~jK~~-&90maw>5;BKi!e=S`G;DAv=cq9FaQyr!{WOk@wYMY>wn!lO|NiWEP8l z;%JA8VCMtnkaR~@uy5vjC>MJ=0XYOncjBA-&Fi&1UzxRoY!rT(#4QaZTCWT*J2o%g*HPSrC0TQuimE!G4?;}CI18wXm>_WLua$eghG_vGR5ICt3HBwh@-;T!cRb5AeJ z5)fj5@PH$5!vJVs1!7~sCP7T~6VKDfkKYw#1F?wl#3$H$Ci8-BIi&nqhm6w~P;}Kp zCpxz%Cc&{QnHTTvXy7Yp;1ty704FE)q=A+=B?7D{4ZzgMAxM|Kl{QK$;eyr%`3J!B zluW>4QX;b-CBEeuoQ%2O?S1aZ9bNI%x?X0(25r!v@e6CFF!cPP4qwn++j0?3yDi?h zfkWkT42|qc&@oA#S@FCzQ{IDsr%&0{Sp|%@M=&bQ_B*{=eywS(hhcIL_zA~TWU|ASnzn9GMJ7$p7AM5L2 zx@vTFlawidcgwiW>;}r@!=sazkXTZ6=?!{`O3UgoEEyPksvofpYS-7yA4qu~_n_jW z@9Nwa^Wca;U4ku|V98kLM^u;CE30kUak$`kuG+Cj<_MvEYW@j%Vi(|wX4w7d-RNaa z6bjeMdFZ^HvID(rU6;~$p>PiImD+x|Jx3$oJMDO7*ZF6Wy}`LAVuLiB4H4VIV06g~SREBsA9%2N@`)Ni z%ECti;uzk+tl8UprO{&;#X?j`@ftV_1J~TwAdLsZoB8v8Ma02e**OefInY;|;6uQW zXjJ~P4_NYx-Cd3!dGggg!hrL3x?R+J3>QvyF-ifwF)L~9$XD6Z(t#|l|ioken#+)5*^#f^Z?;Fk;AXB;ZE^D@1qtIQQIiT z8Rut+lK%?QiBs+%yw^80bgTL%ur0qIJDHCuf~)*^9YTI5C*w4#a6bgMfw;IM#supK zQiY_Tpdea8$i&Pmw;g?^bs3&n5?~0p4m=?(JyTdfSN4%48ytlaZrlMJcH$HvfF;oA zRsjz6$L8ljB2zNK;e;MsDf%;Hb9>KMHS&}AxdFsw^T^t`Hl&rFG{vkgi>3tBPaz;- zU1>*~n|u~}EZBDr2^J*npimx$*LGy7W#}RuPi3GEUT3@HB;c)J}FT_)pj*-bk?rA!{0h0RR?R)ee(1c1J{q8Uthm z)`}X4nu$m!&AXDRH_O473|2A3@sN6?+?Od{|_c%n>TI*>1ytI z7#~FEN`h)+khWB^OF#oB3-XYRO+AFoV1w+MX4ekLUhx}%D%6y~h=k9>?yZ|J4gnKu z-nq8G@~9K)6w8l*;BF6Yr);3z?gY&6wpJoJMbvS?A95S(7%100-pTUg-JzAnT0+>+ zR$u!ZF1;-#=tKY}FHdc3%olMR_cT3F;@uLr#e?e#aUgI?*?;kS{Y*)_`x)^&!V>Yc zJMVK*jXdwxyr_Lzb_*_eh%GKS3JKQx?E&saDKoP9I}gX^fU2s53d=i-L}Fnc{^N=R zCDv00VEH_M7&Zuu^R0Kfo|(M9Cl&Q&SB{nfGQ&m2lkQ4o5X-AL^OA133s_Saz9F(* z3kQEplCd?s>4fj&(&Tfv5p$Q+q|ngg+4NB->gnC;)VtN2pB*mWM>nsaXcbYK3=a*E zBq6wh8*%aPO);}dugCF%n&!)4uTVxDU=?cb*6!;Eky=Evt?-=vr>NDT&8)ZC3E4F} zE;)0~CBYv=h1&BE)vv+Zq0V_xTMEX3mAdllK9P|I2y!E2gR)zXr_;IBE%HB-Dj8ZN z5e4lP!kJM0)IFhc9hH|%(Lb%^BOYGG$H4W5ty5vglIpz7b$Y4ji*{PP%il^@&k0+I z#|VOLRG+vgsdgKT?z%*{j2J&Hqq%28Y`Nv0`S?_USdv#X>4&d2k)~W#0uu=ED;0F2 zaFW5s&z(fa9xE!t(eHu<^H$>N3UQbRAh$D|Inh&2%=_g_bvTpf_K-;dI+r!sBbb(b z8mxXAgV@DWeMoEC4h|Gv%g;l|EViS*B_vn!ZmoKGe2m(1WX@$)A#&k|VHHVVL z+Z~Owg9{ZoiAS8`>6Im)^XI;jFl(QfSOyp-Zl%p-alL4QC}dip2iLYs!?LpsrLf3N zSe39zV4qO#baMMf=can9lDOjFEY%zSUW>V9AR-XIEo$#AtlQXrS(JmVTNd9s8k}NA z<_Z|x>W-yRa8?=C^?1MsLH9@%zxd*dF62-cbkxI^7E0>@UnkOHi;BjCTLLpT#f?b|$%>8PRTf!36 z9Gs>#hurv$I^aFleVpp>sN2F6kj8E1G3ZNq+q(J8LOpi96?{h(E9&}i@*vDE7!9{- zFF0gqf(M$^ARgMEPEyQZoEw$ay@HXE5xf#v7#bWs%7qVrA;1n3^RS8!v2CNV)h|Fz zaZxn?&ZHHe+qKAkgI5a+lRUBWN96>cTv8$`N=gV zDS<4VB4k0^hMVm3_f<0a`RadE*k>UWuR9c*9Vit!9Au%^W63&HcYug{Qrk|P2o#AE zkKL=T^5Nl{$A8*cz>5TlYP=hH(QLarR!Q&Xk4Q9Nhx-bSU56POj)bM@%T zP5whA6Wl&xexK8|5B>rMmR%)f--VZhd4WE)E?rWg>txO)BVUd{NrUy_7Vl*zZu_bM zlUi#@h9zs^hz6j&?SBMnwiM%A>T37vn z>7%Ho{H3GIg^GeZB;wvA#KyLLUsLJWjZws3{ymCSj05H8Z)KeD2}~W=2(`wAe>8?c z1Ri~&H}9F~GCArC&Ix7_hR&R`0+uaFm*6Jf{Blqrr@7Me|MH%9#@;iO@~t0+>39Cy zZyfRu6$**ne?aOvc;(g4n&r#CO2JIXU}G#!OZE5k@157jaLY4Cf9U#uz0Oo&_sHj@+#|g6 zIIH?sO#O_r4qeF4VM?e$;F=?i?`IeseGo%?0q96ySAS!WR*y=i` zKvj0*DgPn-_FkfZQ!h7CsBl}50RjlG+_p|vyYj3*pgCwq&aYn=&f^+vl(AT>e}8{J z0uYIDk4M1SW_Zan1RbxX`36uh`2Y}+x`uxnEb(5MEqZVKH9~B|G%P)fZbrN)M#Z7A z(+GOurxVm+dQ6o2e^v9+|0x{#K)Nrz9-22u&0vNJE6+F-3VjbP&jPn$K0>nQiOpO8 z%~m4Z_5&~gf46L$6`nA4)Mo2{R%h34z9mL|I;#6%h6{FA0>@Biv#>X!zj@ z=gcY37)kEW3Hv(cGVQbkKP&rBk`_oIqmLvh?I3yy_I_yu$F>8+PUj=&v+TMYjC#UtPrV=A%x|%PZEL-Ea0eJ6tdO!6fj5 zo;5c3j@-1zY9Z2(A0NKEufrF?n`@e&ToC$y*n9JMEYtsQ*wi%bsgxE;TF^p8)+`m3 zvSle{jYP60WYXGRP_Vx8e8U>>I3yAE?vcCmMBLEZR+aB`|iEDTJM`oSMORlXGUc-f`e8pW{z4wbkAPTi; z4SQ)>e%|eH5(zpjgRoyOS`h+K(+uI_Hp1gieqGhy0(SNJ3x%B&CJ*NYRYG=}!vi_)IWLr1Km6;GUS8H^^G^{IJb7PdSl&>3Ge-l`B&3t6FBQfjK8u7gJou=j<) z-gmK5kRv|UMMcDDSain%*nAt&8G9=X?P7CN+nM>zVPL63;l29K(3ti|{q*O}s+a7q zEtXfMH=XEm5K0~!s8p+etP`$%$uQ=$`!xH+7cBkW8%YMWETiHetM89>JlokVTwG5Rcp)}u?YQdQ^4KWSXw-nU>~qx5YL%#hix zF(7qRl$5GId~o5Hk(ZCd5=Fl#vUx9Bu%@%{G?!s$n~whrY6e064YmkMMJB8%mg|xl zeuGn^LgyJyZW=z?>~S$R@62wlyF(J3d@^WJVi62ULm68Z`z|Mk5POIGFDO9^O5unO zH^PEL2Phs2MKXb+$G>SO5URIbEqcjH_F>D}*2JPsT-UB&9vM(S>sdcfjN!~n!j_vt z;I!h1b=m2dizk_`?M!7~7ptkrRxl86a={I8nZJIXFEBc?=v{wz{#&0uybWVT?BzSHpDd&ELFfxn$DV zVPbzNfXrrW&U=)|sq4$$ZL$$GMj5dkc!UXy_p9sgNJVF+W#5jo5z|Qa468pgB{ani zzXn4!sD(u(m z1XIopYmHt@X%6}uaD>W<6Nben<&_as$~dww#C*YDkiRO-{Z*l5KU4lS*%N)uZRn5j zC`oKQlVZ^iyaq<1k^a^-TwJiljy7FiqY@9vC${SSW?i3=k#J-_=5Tz2_X+0^N69mD><#TFt>)txiKoI5&`R_2d>TCdmTEzHnV- zVEYpG+Ks7uNRl8@@Mae+^ z?$2`zR4(f15=tO#bqToSfqVs#TH!s=9tr#ZfW- z?B;9E>{HTmB7+czzjdn0$wlLlY4{s5-*2lo1_99Xw9zf<0 zHGM`Km=jat1D3riQY$lmsPI-_jw{iB*ZOc-XfS}w06Dlm42|fPels&W`)lCWY;>+% zTeuRt#Z0i+dqgb0ml2)R%zR*5nY-flk^iTlve!&|eN{hyqG6UO3WuU0X?O6eu_EXt zPkwVF=I5syhpx0~Uj!!t_2kMQ#l=mplPicYxLQ_EuN^}sO*^W}7q*u8I!Q!})oS$` zt%X*B4)`Eql$LCsJaH-iZ3;IUuA8M9a`AYMU=_jvn&w@M>y%lh)tkPYeiaGaVpUC5 zil3H8&DcX9L0+o4{jU$oU;579`VQSqo!|)hY3XjT!6pr6pJ? zhm95&aJ~Tm?ttrSqgUaF4_&~mmN5vDFkr$fLk81WTLE?G>?OlC`DGK$oTsSeAahZA4waa+-!!-F-fJQNs_5!jc4g|MnRbEV%k zOqPylPG8Fg+h6jBcx+^ekOsiLpeij*CPyk;ilqESdEq(fV-L9_4wDS%)Iuu~}lp05&(myR{v1vrT~DppZxo%Mc$_ykzj9vJ~FkZ`WFX(!x?KCXD?-br(1qwiV9UJ)AazT@A1 zLZM0M^_qXVZikmA?wnNtnBr`g#n`nbKPTXeNoc8xE+g(K0`8y-NF%X8XE1fOAgB`B zcE`5kvd`k0i^e1t2HA8+D^V<}g4!>M*3X_(c=Ad4J=rnx5ixA5j$93#Sx&LSHy$gW zWIQ7pbxFrqGb(`7vY(6&8!U-SUCm{Ks3&T~;K0SCHsUkh?4yDbYe~jhwm7W&hA$tL z1vW^jSmTN{D-7TyJB^SvN=o|;t;h@j)xXr_VjkhQhX8G6X>zMH+94poUeBFCFpU}v z?I7wn%oYI;<#!D3pzbDY>j z27#W)|6XrzZ@o@sMUu|I*{DlSLl&dO)bxD&FnspqUI4WO2JghspbS$U8N|%0t*zhb zQz7GCH3H^>4DJFOiT#133uf1Y*V?dG-L8Ayzo=_h*mdlntwx)H$4=k43yXzB z!H_}T?apiWszat;0JNna>OX%n?HF1Ko{CQ(I9gWz5ghOUC%pk@VkY1Sa5k$|<1~1i zcYM&zt}R6M*jAG^fqJhtE&C7(XoZtzp&%Ey^!IATn)TzDd2u?T4#txum9=RQawWn8 z>p|^BXPxO&93JVmH5nGXd9 z?-A(de}MBV=sh{KBv~$_nW|MF%{|H93p5dX=o&sJ_n{R=@g@VK!Dd!F$c>FpgAw)I z5=ij6P9dU0w@N+rQ(ZAhAfw*MPi7LRjlkHuo?YWD{4>)ZcrQe2)RK$o2>uWiv517z z8}^Xd;ueNDVj)rEyfOV@@#~YMNu{^36s`&i;aAvk{i^?g+pnPKM}>)$7(q*gvIR*6 z-5B~pa&BrHfG?pC*^QcL2EG3Xh7bwb?0lrQMX!Me5@H}Es2~MAM7#Rlm#3izqPD$I zN#UzX=WX=lz=AY%)L@k2@6N1;R_gGIGg>3lEHSnP0GV&WW+&K8Vi=%*p zj{LZGX#owh-487<8;Dk1jt_bu284zyN>iDZY58i(f4ERWtYvJ9vAg<3n>ff(FMRcV z7{F(62?N(|8|VQupB{vTf8VrnmPCM}`z_0u) z3qqh?MR_beTxg@fr9&lL2M<~PQ^l`Ec8s53UgKzSRMm}3@C@OJPoMXJ&8By*s;;k5h0Fc74w)lWzW!bI>j`lviuEkHYwJa|1|LaRpQ z-+Dhj>Y1INXs_GPj;T%Pb%a^`QSs9v2R>h@Xq3&K($MEI3sBku{goxMNWBK{L!pz+^{*Z*H-3Vsr! zQ^*H0O6(VliQ^a9zFJrtKVBX3`)SpCgF;<&p3>R*58Ox2K6G<)kOJy3JdpV1(Pch1 zb>0h3B`TZ8N5emwtA|t{jQgN+sZ|#tzk-gdl)p4vyzYF)k=Y3E?Bbe5cVwICzW)|A0w)7E|K+D}43E2&gaTI+U-QrEszx z6(N^rvel4LT1?U#!l6)2b_H**QBh&SBDw<4obI3TsfRJ8&(FAuZbi$sDwenno$0v4 zRnu&s9m9YnD-6H67Zf&iv@$H+$Uj)F&7%BjC z!Ysdo2meMN%&l9tTs8kh8;Zb;>@^s`fZ3fgs@?D;YYZBr$wLRf4jxhB7Iqe4;8bkP z3<@~4-L-|&QkL~$Iog6KpRrw4hfttH%_J)&u-aJz^2pKUcFS)X+OX)MC<|tOy4Vuv zc9<4oRB)OA>lD_@=)hou^rNWYmF(pW`dUnMfB@{Y+Y!)8-M%(j(}Qz7RiV<^y|2rx zz`eE=IvB=BYMUULOH59-0?qDQscKSuvS3i6bRi_i43di_UZU7*ibf#TOS)zDchsRb z<9?u~6Z>VQ-W^5SdX{1J8x2riAcAd8;S|;#$o=Tu{6kjSdkGG!UmUV@udj}rqNgQ~uh46Kh9z+ff> zKw$1YcrT0$)5keKDYUG|U93X+h)_8m3`uAxC3QcmV(bP13*!R6!?m6SJ#tNx;A#KA zT*QCJMcC+!=q&w*;Rl{PPDQ7Vap)`#ErlPwwWUQyYA1xETUX*P z-A>4v)~??+1#Kq;l82I}+T`>2&d~5SH>H(%<*C+|(R3-19#&abh){F{{L|7(NJSS^ z;*q*!<;7uc8=pmts^R+c6fg73!)xA)k|hTaXQ zNrlo;jo5mFYs13Boqx6sVClt{{U-(pP6qV|_n@aJZ@Wtr4X#(eV@ujqk%CZ{bW8@+ z@yFzq3KD$s-(Ge|u(z#rL^>`p&yNyf`igDpbG-WjI$mbtrnYdyN zOXt9vZ@#;B--Q##FP}c$+J>~8n1WK$9XLbP_YPJ5enbx28+dc?9QTR5vt#*~f1d=l z@8@eg-i`*Yr{9i|RzEC5oS)@~xA6H&?_)z?EUnI?@BqKdpUmoS!|jBo0IPWKkaXVz z&>k9Q{ah++8K)K?d>HNKEz>yN@{gB(w6Juulc8HQ_NNvkL20+ZUr-kf#SYiMSF8Rz zsWXj}J5eh~yNKM6P5%;UX%UA^XjtF`HjtnUq)%qN#^H32;qXa%^tk`J{T@NsCORg? z^~h-~3YK+E!aD2i1UG@+u&YL7GeMFl#-TDV_`eXKj}wF-H%HzOMU!(H&?|CMqKm4C zEX$0_7o?MX1a%h0B_t2sF)Tph?mduo+3SQsCMU;~ngY5>nMr?C_t@N2=}je{J%uI!Jg5M>6^qIskk z2YfNGQkS3%zH=}%r&RfhGI0+?#hR&xT_ol%D`_;&D+(6^ys<~Gtfa&b-5L;zhLs$9 ze{@$6y|sdFA{%DW5pPO~;M?4}jDgXE(&g$qwLTe=Gg!O{IVAM4^3P9SLO?nhQ<@QU zMSS9x?Mv>leeqaNkST;GwuAs%+oR!ggp>N1lfm2&u^;Ii7S6u*a$z9Q6v&iXt`D|w|$hieKU`z6AaH>JEqT6nW(lGid^mhC=|CX&GIaBIkE zVw#qe;E+a-^>`cY_5}wQaDpR}V$lOLpDlInBG$G{{t!Z*=`XG|C6Qb&6_Jab;_OW^ zY`oupjD@wF!SCAuV43}!!3A-;j_?)dz^7qh*2Jvlj~u6U4{4(43Kw2O^Tf}`tNZk! zk3OL+-z##oQk+=Ej+%0K<*+Rrm(t&1Cp1bdc22aXcy=_W>%UpGw&FK`FBA#ob^{b9>>t+^6Gdn zDeueEJAS(Xn$8dw>8eXLV#`E2*6g~G=7+hXN^t{;e?Na$ zj!5H438s3{TpOJ&MZchCF6^KEqSJ!*$yd}mhfXQHa-FKOU+^T4QT1&8zgcZfd97E> z65_p;C`m;ih7ZJD4<|07-O@@F^x`;0%)FCqM(&R<)-_RAKi+#;L)q!n4X zd6VcK*G(7CC&pB3kFf4IyxrMJA*iUr*{emon0-&)cs;dl*ruHfF`{FBJJm%M4vZe~ z4fV6x(Z(Viw==%xjPRx>q7ql*Gd9TyuXOp)>8>Oc7svfg@Y^S<-|m&_csG0YRyBGz zj#_wsTbT5XWx<@QJP7FpC38B!a8x~!thU4eh$7J3jX1Vp4I5xY5>yacFb+U6oB=zA z;6%`$Ra_pTlKf+Rh0rfU!b8xgQ>eUzYD!85FvwXIh#suAWa3v9a`A*FFmvV>gOH%o zhSW$TY(>XWbow}PT1=5V1$>C-i!*T^W41GrmdpprwTRhsyN^Dsj36dp__^~;n5CCk zL%;aoAxD)cR{8P9__4}QN;%?{12^bcv7O#hKrw|HHV%Lvq+obV2d&|3psmi&W-n&_ zw)f_?>`QZ=>dA}nBJUZeGtUcUQ9E7Zp}$ctK%`S&@WW1XwO%WVBnmWn-*j>UB7Og@ zifQ?IjC?TA*B1nhoNqG_1@HJ+ukHG`wo#4<=^|sIgM*uqa5RkZpBsfHNIngQg%CR} z_DZW(sKJ48cJ~4|A4kv=R4tQiN&~aD0YOBo+Yi;PNgF(zLyuin0Hq(9#&Mq?hiQY9 zDqww_Ziw9!DXm3-r}-!?tfvI+t$28(PpV?ztBhtv?9r+Vu%G$5*B-$?8p4YaR~)h5 zd5;mcp!&6jd&|SpwE@vAt-rtJ`&%XwVBw+%x);{$82WsT`iL8Y9f#T}%jg+wi0boT z`Q)Y-gwv=Ym+9UZLcB3NPlUhPF_#IU*Y(XOZ0g=jKu7>YxVw*RhS+hQ zdX|^BbLdpc>PMru&Ai>y-Z=T*59@N>ja5IxeEN7SPP>GA+4bJRRvtg1FRvWcr|uk% z^)Zlv+ah7t!1*2e$I|tybl`j+91>dEkX9~Iu^9LE+%X6OR|^q&ySNu$KMZe}F{%jJ zQH3I$?wN&*(iqhtW5nYcziCJj_CVqW?8z245oKfC`Z0E&&V)$O4q12YO7G6wMr}X5 z$$`;Jrf1(=>U>lkaqj3^<2zh48#&d6G`4D8^^6b`iw#lpWS8+HWTaH29p*xFSm+(w zV6z2FK7h&$m48>-nJahS_Gt&EQ02UPccs1JK`;@aTSAC~ae$8pXyI|`HWszB2)wTZ z@d9GCx)rfBaE%L4POO}|tXb%$DDU>E2Cl1KWp9eK%Gd_wxa8N*o;=xP!DQU#65XC7 zCkbf8@1B~~erL-!y4Vy^2lDqL*r=fR^A4J#jdEgnW|d+` zY5gPCM@F9k874AagvCMiO0|h2F_uDJf$k^~l0y&u#AZ7KuDz&i&`_f4>F=t|Aaff`Dsq%W}HEO{}GClCwFk!B4$QTe?61F*1h%qdH zGl*=lz~m89E$e7&|B7bKtq|H?PT3Hw4DO#3Vtyw_J|H>-rFDC0WFp;h?L5hFd$~a0 zYDi7`ktwXv?yy}paF=pokt{W`o|W~IsSd22Lv?UDRpvpesc31k6(nDIb$>23VN0J` zgB(Ty2ceCCxAm6?^0+M^{)in0eq_(CTqZPxha&Jq5IPugMBrC6LTOe*J%%-ShzJve zvA6B48?E~aU`txEeqR&Tp01vr#P%VuPS_7?U>8ZB2dY6NC+PO=+vu1mPo1>GiLaPA zUD(xc=!P9IKu^15{8%>S!Edzm-!Q^^CNS{}=Me__#mTbzn zBC_C1X>sv``}dJ7Z`tyjt&V@_?KM;)Iy^}OX*&L#un2nDGE{aii$+Cp zBt4w^S8wNx+qmbsA0L~m&%EAi(oo%~Xu4QGkBT|N#9-7%CIHYJ$G7Me^viN#)MVTMBGX2p%$#>YJBABizy7M}HSHqnGvxYS`Xzca zcrh&s^=6nb2$i=P)T*8?N|%=gcu44;j*+}`F7|BBpAK`VmdE^dpy-JFxnwGy1jetn zf-TC*>l7mgXMVusbyzLtv%M)Qx3*(h?3*PMDW+LfQa zoNB>eRGXH~p_+_0e|moA!MIUDb@f?T=w1~nW16K7kG(5mV&tkvT=@-jJtA;I|;w=fiW{r1YypTDhMlx<= zUW8!8R;`@M{`+xF#!Wst;R#GuJCh~H!u0;M>z*^S!A(s?=*ZNhG3&g#eg4KpR84u( zBWPlRpFQKL4zc!36Ym)8ub&9+&1UJne?=2>I6!|w8~YM`GYu!3R*5DJvqA414++m( zzHN;7CC{LgqbcysgnE8T1v>ZI!|PRoY6Dsf^d_GF`HLT|k6ZBlN5@K!;8*|=nvA1C zGVY=eHd9IS2a@@Dld(8p`?V~Ndf9uxz0ho&6RH>G=sjTPU2*fv$@b6}seDR=T(H%- za%bMk@8=a+>XYZypw1ctt_ZbaJ^|_Qg0&}^Y@0n@hAgE$va;Vk@wb$Q0&+;yc6k_(hqgi{BwqHiAhOT5}!`{}*b z1VHQ1JFfy>T4CRYb9<6saK0in)NGO(_s)9Np$F`_4m4%j+76tF%0tfQ*f{tVMsvFQ zQS3Y9Eul`wG;Vf4v#6b~Yflgn;P|NKV>U}`f`Wj<{146?<$a+{w>gn5 z!1KOtD&zLyEbpFX^cKR|XjRv&%(6kjkCSkrE6K=VqlpW+y z!sKGEsWv4P^|v+9j&5UAExTwHF@6ur6IB#1)U$&_IE?Lj;gb$|@q$FrASi@J)wapk z0n#Ex1qDpS+z?Jt9W~Yq<&G8Hu`vdof=Mj)eISOmgshmjtsh_i$rKY53u~YmweKUN zVu;V7>(yMwF`yVCrM!jIv6q8YjQ!ak@S?DB@!osx2n&a?w5SjU+<3L#9_;sJ#TC`o z)xGtN474j*uem;0L?9ITtBV)IwCGZHZ0n~#k^Pe2-H|QPk=bsv;ksdxix&VlZ_`>@ zUVW|tN6FUHM#f1!^ly|j&7ub%5K(-*!K0S!atzo`bgZO#`w9ylIUbASBK-YcHbLht@{MyBGx|k01+llXGzuCP zD6d)RNPmEjw$jn%zSv7qD`Ut*?Ar+Om7fajYA3*0n++u(-6SxHv1S+oz4mrVe`WG8Y?QlZS@U&NEtL+}`0r36wD?am-1GX6j1J%bB&y4@TslL1%j(@k6a{@bYIc;!j(1S&XBDk3^_bnu&1zd>3 z#5E}n5jj6!W8!ppF1xudxU+9RYoTSJj>>`+0f&Xi^8fayQ%9l;#t*(%(CuK{{vwx& zfaxwNZISQl$_YNpn519bIAX}n$$4GZELh=(&=Q0}OtoNyGoW?CtOCXEHbku!Lc%gA zWj;~MFwWRLw<0Q!Z>J}M-;WIqllU%_*~>Qa$D;)AR&CH69?~!IS@*5AbhnaA*`Cea zPQzxgC}1PfN#URdp&FxDCFF2cjF^pwciFGw+$HwaVB{! zK4w18Dbqh1AeEHR*gm;_?5a{$q;UNHmB=u)1or_qLp>cG6SFN3`N#S&Ho^82aT-=A zwQg51!OP0wYYwy;gJ*t8BWb8XMOtBe8}E1ze^}GRee2{o-4iU&7ci3qp$**Ulj5^+)y2E^=7Zzx{a*ga+x+Wsja>4;>CMe)2v09N4w8S`WV#mg(%w%$u) zr|gDc?12`gry#_JZU>Dri57EcLxyu<38SCIirY8YE+a69Q2@6M+v@AIwIS6BpKhow z@4StxyU@j^NVehCcyezQjG)~e{1!fUQ=G@L)ceE$!JNr zvj9z?D$LDpjdsp>9HVT0n;db8&#JgRZalKn)6-i6of(9cuwDTe@G4Vhs{3&zmN;%N zh$s`6-RzgKe@yaS^tXeh$;&q%ce2?L0>U_*H1=i8{g>uP3bI= z(rO9!dc^5AVrB3|5{^8Y-V>9|hD#ZyuhjXIj{q4bLakn*P$B_uLSSqRY?{SX$(D-; zcMY488NY|b?|58{A$5RGUrWSbjcq2}%*6wAF4LuwJN@sSuj$AMuCTrj8(Ms*3TThoprKHJ?`Wa-h6u9$PL%;OB;mL^86B zSy<@b@5IaU-#_i2Y@ZU^+d2)i z>-MJCOPk@>8V$1!-0iXlj)w1j%~{Ggs6#(?-AnyVQT$Qcbj=;RyuPJQ^%TJjYfVWy z$o2$FCXD#0N9_tM5yU+OBf+rVVpfL{hKX*5iPDK-)xZ=RF&ipZhKxe3(Cc{u54N+m zWWMq`FvulaFND$XaT#!M|byJlAG6c+IJOrgF!M(WmSJ^|AU z?ESBU+aDHcoH!M(Gz4TY0Vg2%KoThd2284Qdl9Ra$(nsRm7m{RY~A9W<6SuSZ)j)p z_*$P6eg1svjeT*klwy5$$Mjq#F@uF> zvTAoa(Xt-5bOID?jL>``eMrFEv3>g) zbT-EHHke%h#-QNC2Sc0;fqL2^6HoOF*v@MvGU@p+2XSz2RqB#cZ(s;=It4jPXe)>-v0pFPr&;s}_AhMZ$ z*@E3e;&aMe3bygY@<{t^1|YK$$?_ALGyo{Ojah`c{l~y=!8l?zjBI}5_2RdNR2-8{ zpP)RK6%hWD;M9G9p~1Ac8ohC1OMDg>7+4=i4EyLgQ*c!sUEN!j=ZIHrUbQ-+@leGi zyYt>yq9m7zoqRm-9=vYvK9=zM)w+i~E`51~Z~=m68a1Z50NHJN_*mWmaBOv2F9KRw zAPm5Y?>B;IPTuT!vp*6cy0G6nwxL-e6#v0%e`Hlab7V6vHFwB>>+Zl`P)MD=hqSt^ z{50r@kUNT-c^MlBiW-jrT1*rL_KvC40rxlLZDkW3KLtYsW)FB+vCGom6IO|^u-3?}BB*Unc*qkX7u9rKOt@+pzP0s??H*Q8X?_*=D`wKKYT8a zcAjt8nP7MQ`ae8`KkrgdrKZA^y0RSaIvo1Pm>3iInutYcVU4aj!duDngVGSY3u(6_Anz^QLLsVn-5h6wm;1iZ}*c%Zg&8cGJwbS4w(s6; z+i>@&U+W$`B$=$~^RyKo$Jok5Cfs*#e*D}+LgS)njI(7||8=y$Fz_}gIM)E@furxA)o(-NXW*I>}U0s~~ZN#QdHeB!tKK3sh zlHQ|=(-VstkUGxg#?GPaYJ%Qw>9GVcXSZ#U(~N@r)P6Fc7ATl6Cn+ZO4xmcD&YG2s zbjmgr#}Ruw#VyP}f2J73FnCWIh=x;JB|F-Z;nlH~-Zzl6*)nzVHfgS3#u+x+8~)J$ zs0k&fsNrosFAlP+vf1BJ+6`$BY(JUg_0)VCVz8|O#qf!obXT8`dJ^26=fUI=iQm3j z*j?$>xn`HSV5~;>`7~9y7YL(J%uK%D<=8)Ygrxvvf54(Wm!wq>+4!{NfRM;a3u~FE z4B0=~a~!l;azQe0M&q zzVt0)TxcoP)xw7p+%{CNc3fxsVUw7F<*^rTS}^ATclShz9_xjgb9K){a>Ot!Ir8L$ zWl_%R37OuS+qt}fIJA3MB-;VOU;qksE{2aa`Mg! z33aL$$CJPqfL9w?&hEt8j&aI;f; z%fUqBD95FtCC&d8;V|n;Q!dem%LH|L616zklsV=-WT_5qF=#scT4*liW?#t5W=klH zyOAn5R7kjmrx^zxbxmE{$zP1=$Zg@PpJWo6nimJVCYOuMW)DvKaOgxiR;8KsaCsnp zV4?8xKQ=FMn9uOz3tjC%+d*~U6C}Vy;0HbVi=f?4C3`e!VANaO43NwFtBIgFw}82a zM8nAnlBW@g(RAP1$bRts>w|!Cv(!@V=^FS69seq1BHYeWNA%Ko>%`a@8=Re4YgDU% z6QhcOK564@ektZVK7U=_qfaYr1=wF;c@&yDhi!e1c z_p*V>@{s$%c`K?tI^Vt?S78I0U;BK^#=VOUKGS<^U)!Zgwd?$^*VN17zLvAVh->;? zC#rx>LfSKIwYNYYl9EXjdx_(q!MLjskv;=jTq_g>gw^mXlC9NvDC2qi07|-wN&hWO zEg+|;So!f|BwF&W0ub8Oj!92dS7xbqEAP0sSX^=f1~xw2PK5pmTc8}G-|Zn}Ya^Nu zTfGt&bfYe0XjoJUf2z~L3(cQQyP24Cc0sb+49jeg+^)p9`ep*|_u*VeL?=L(H6izn zB!Ew=snw#JBK%mg0=@p4NLWxm6|$j4$YtUah|E5GIBuuj^(`Uy-?98YjbIcc+?rfI0Zo7N@DD^=?P6hJVP%D$DL1aT z)|4>`9jF=CeHS#VUK`6B7W-8`h)rK{GUxoNzff(vEX}CRB_r8?$*5w>{dRV;yu3EL z@#YNLOg?Z-Z0zeOl)H?TP%kt{xu(GrTT@okiYQOE!((vUJcgba&U0{LtJB!q1F6q+ z-m^J&A5G+QhAeJ-9*oQtB|5J+j~#UQxOk;CVVbD`%G%scaO=2ONLRTFwUM_wzC*m-+l>e}ZJ*GCsgErJz&>7h;S@#iQv>|mr*e#X$ z=~gmYLTkF!_VFl%@!~*%B^kx$xT}T0!R44T(2RTGV>7IsnYW`F6(-=)dw34+`*ag)va#$ieHw{YkM>GNDMqR>NS!fo;*7_`drNl1*Yi&vA0Qo3yAWl<58c^P~B_F8(ZT%R&TlaMX|LO+A{3-q+PlMwxub zw7V`V+lR^48kKSFdvJh2qQ3#8eU1(QWM;xDPPlMftRX~8j|;>khm>sxGnGHC2M!c( zGlOaJMU-a(MLA@FMhyVXE&1I=j5@hQohRaY%|&#)kEACVQ8yG1~B$(##c|G~S z?RR63C)_&JMY6_&T)F3--M;vj!T_AR)xzQ!FPl&ah3zg{OXLwu#6G$Z2Z1dy`r=I> z(c1Q_yOxZg#C8p6rd5Y>->q^Bg5izG#W z^2Ke1Eu7Hd3`dljn7X6Jp z>~@!>965B~4=(bXsUvV>2l`6H?`-5XYA|qe)J<#2Kc{R|ce7<9;D4k-cdhAg<^h^f)^%eTRJ`DN(iQCCVH~SU2$Sd-sW@WC6DHqx0 z0`p2I&nm*Q4=>1yBGP?wNdAxR2%a4E! zIj%?;5{{`ewCf^HAK%;_6jWW&#IwVceZ5JAX_2-{T$Kc-(!h+q(fnU``{yzit7dll zeVm-6BZJTmubBY&xu1?!q^1B6pi&%7@heHUC~UM4WPbgsDJAf43l?>~fWwrMrN@#5 zY39WH+2(W$>RF!lk|X$Uw#oi4Nn*zP0Li1DgD6W3k)Quk((z6etw8_PfL&CZE>bX%+47Rp0eu^khkLWWmxerr3^)G= zy|1c9Lu|Q-Q5`1vYy_}_;fJNE12I^3gF~1H4&&Y+_RtOcE_@)d0yt&{hX6~TLy}5u zUDpE?Q)t0~3Gsm+J`sm(W}@-feuY9e01bp}4mOh!MD2?W8AI2L371Irwh^H}6Zpf^%Tp?ov7~BzQr0k=04gza|^Whr#*@ccgb6bEes(m$W%Y zTTjmm)_(K$Lz^}H-%*2DMz+M-|9s<)lAXqUI~&HAG@aN?BI&|Z(^3->hH%J{X_WfN zjLu)P4k*!HOv1zt3vd)kDZ*rSKz$=I(Lj+h4E^FDbiic5JcT?PAMMRoMPN5(dXnqr z>NC2vos|h`j7iF(m{6D|noy5j2NMvxFfqd69vBSw(PpvTDRMG0k?8uH_(m}f;Z6;) z>LW`+e6|K!0t4muaa3IZtwzQjlEsE_^4C!{!zFQkm5+AGR`;y;zfXJKi;C!HO-r7i z@x8aS{m?d|P;TnEX2|sZ;w+ge6%|>&R98mFzlndpU)3rMz$Vn3#+zH~WKW)q0l^BE+I}K8(Mcpwilcqr z2^E!8^eN&Gc(Gz}Lh(av6dm_0EbMlW@a7_pfCkXhcnY0sE?&4miJ-~#aN}_gUgrBg zlNE8}_hJLij4*HW5Svsd_p~%N5q&5m5d|E+=Nuq}pN8q`D=$^;*F+#0!RUTe!NY3P zNP2@=1qj*4;F1y{A%G}daX?f9$07OY>43xmbRz@>X`>csQwa2LSu>(CI*Mup%OE2g zA=~C43;3mHJk2!ZK>Ya{{R7U*v|GgK5F|9FJ!ldC4ifI4WZTYTo|6wUJOyMA&9k1PaS?Y?K|ujE zosCM8%YyQz9bV7kpA{dg%l(zDap%rb+XiJ>R>2qB_Q_Y`W-A5vl$;FvjG4hxN<`XJBYcPb-Y& z%Bz}B>V5HH1{IeO0r;dy_Ws5^+ip54(KwL{6ShglKWcubk+~+xqW}xjx8+kSN3_+@ zeH%^#OgQ;9OZeOLt)IdBWWSY_hvV*DVe5y}BWriFO}IUt(AU*3A!sN0IzTbOeg}y!Ex)#88uySg672iPt)ori*{Ro2ULA?z-ywwDv@yY-KDL z7x&NqZuuaxE71%C5ne)EsJuH5o5j*TZ^pS+&cMOrrb7wfg$57ajYg>xc4% z7+feqhIfu3`R*vyJ5>1ro)uyGA>)F%E9^5oetUU;-Y-E)K+6Pi(mdiNqEBoQ7)kRuqo9Rc@P8Y^c7k;bNCHYKKxrMp94D*#z8nG^{B zCgKA~&a@%ZP@VYUc@(AYyeWSza_G9z4;$C9HM@z}rX?IO;_}9{dXh>IlL#FNw|-Sw zcO!;6f!$#t1BJb$Np^(CIxh?+9B4?QJoypJgw7d0EZ70A=?bo+g=Fjxa8WSz5kl z!>1+xTCL+|QqNLt8DDd#L;M;8Qb`(}98HPh3MxfZVxpP0y*{JOdJu>rQg6_mNLD#W z1DfHED6maWeUSgDbaN5msbNof`0!zt(;A06WWc0k)wZ#FEVNFYGIy2E8Vw^_ssJB^ zBz}Lyg>5%0A06ryCSX8qB%H(!eGnqM$9(NpUUEq6TdWf|pd83=MTr|GK)oP^UW#bO zo=$1T@V6%d?(3jpm{h!WVR-46;+CI8uFZEP*mfi;Ywz439iViC@fp6m9Ob&%e_k+| z#=lXyxZ!3&^!MJAx3@P-1d)4*W?bV@qeP!KREX1>DOC`)li^qN&WoFId+hoE^LmO& zXJgFbdcpO)MjFf=z^PU_f%Ik4l8sd^OR>Bn%D`*1^(m07;_gTs?=uR(WmpJP&<{&g zd_2!nVhkkktaA_vNB@Y#jP|mRXMfb44#o0Sv1t$&V!r)Kqbgm$!Yv+F2aS_Hp5pN% zsK*D`BO}##_{V?TMyQOb$sW|x3vJhH^$1ERCO%zBNr_o(K)^N)gxk7}YWI}lnV=i7 z#K+7FcqRl84s_@SeltVl%+$#TR`)TsDu3MV7^%l~x8Hy8^>T253!BkiHJYSeKm9x^5i*=6+0Zfk|Fb3iCw`sA9K!1Rv*(eftxc zV$4B#qr|g3slBvLeRKg9ur>MiB5zeF;3)7YK%lknfp-=@7u)PKIVTP@CYfa#7uY|WOcsouVADY)x`b{TH zWSn};KqFIup~GrLBH2p<12i()4_TmZ?}npF&w2lT&sTSI;e#Rw;`vo8wZKaY{PJ3J z;N1`9GV&L~sKJMpS}!+Qxzwm|I=E`AjZ8dxa{Q zOzzom#k48&EkJpmr^vE%XW5~X#4h0&O~HMUW6CDz^^XNMYg_Rwpw7YvQ;#SkO=}$1 z8uARSh^lbXqCkNC9_{0dAYDdsEjuqyogYr+@mNsUJ*Ci1059BQ-} zA$g>AH+LTD#aWYzt>3v6JDM%oHerAp<7~`j`s;*(UtYJ@dNg+ar#!vr%UwXxXU8eq z=3b6+Y?5A(I@WIBp!-Przu08II~fg+2(5xDq;t|m-h;~#tJ>cf!Nz;(un9H9PGle) z0;Hg2k*s730dsY7$L`&o;r`3{gq9p!djo8~yX$}4(x=YnxAkQB@!;Q3_g!&X2=9zI z2}EpiCCRDLEq<6qbP0zbuzipfq&qqZS7=MPFn-KFLuiFGxBMIMM37SZsRG=&)t4uV z%i~7CWhT~Y$vcanLF&N}JtHKS!h!&yo?UUMyiaVkW#Ani@}{S}bTg>YWG(HvUL#C?$pDXFBGf=hC38vAy*vF}&ueIqm;ffLw9kk|Ui zl}>Gv3k6SMf6ISB`fG13FPukpKn1PuAJiL@aY*f=Kx$3H)${#2&5i)65QR9eW)KNF zrF*Sg+`O6noFwIhe9F2jp!P?i{pHJ1^3#R!MSxkP|{L%(uUJQxMI&2IF zWC565@E9E(1&o!&g;0&|2T0z9&YuLtVDB2h!{8;(&c2gtMOqJZ!At14b}=(I;xG?; z{`^)4Eb%s7f6vD=N=P9je@6igN}M1{6{MVIWY;p29n``Jh?f~+1bp=fu!ou@ogQK( zK3LTu?@=)>I1G2GXnFynI!SaWod4Pt%fpARF^q0fnz$#%28dVkBeO)I&@mrwSDipU z=ERl97;~@L`D4A%vulXL{5)K!5X)OjyN-*qS541+>{O%pM?tB}UN}lXrYaj9I=#@N z`GwELj&@}8qxTkEG3z>bj`1Oe<8{xXNq?B~SisxdTmh>=L)~73@vjDCI|J20RDktx zid}*D)AiVqN+$_%Po7KAp5q~<=I?q%PK;_Y=j;>2lmz#K?5OKD)S`$&bM8R+qF8CT zawW3k$V6bsVI)5)f(JI8Sg7`eS!n23Iz1Asp-)c%@MHJ|1=kw}YbXbMdwMV|#4U9x zC8anH6ZdNROI3aPjurMveQeBacpRK}tfH~IHF)Dz&)$ID*1>>biwp0?@@j7>EzjI!*UD;7ICq>w+ZaskfCeuG&tQaCFmC_4q{H!Y zj~_2SHp1N8t7()g*BGs!ySez9-DTpR5tkIJ__~kUJ0w_deaXHorhBnL#k}q>&p;en z^L8-!Uf*!=S8sKis~Gn+OTI$7#RdpDc3lrY6tU0>*6wSuDXWk|L~=Z`M9|dP zp2S20&`b0?jKL59CjVIRBjj}xz6u4gxP=o=l_rbR3){uxjnJ<^s_;$~q-H0q zHa>XdbhSdb>~vo1M}Xg?e1fgInr69Ar|!ens%;sC>K+g`1pmQuQnqs6^M$4PPpMr8 z%tKmNty-12eqEplK836U&SG2{lYOCK)yL``YR+who6QGNTq8s zm5Q54hGsFz2#BUDv-7jYx+6eQQj$+mhI8tP8rgHwCnzxwZ1bUW172jXAW*&CWBszZ zDTM1f9g+S&KUH+^c1}8ojr-l|z>E+o6^RkXy=YWWpp$AD^c^4c(aUyo6;xGiU{l1f z7JLe6|0D1-u_fAKz*B81FE1~dEDDs^vyYbOvmt~S9%vsK>$FYt^<}KhLES=X8X%GB z3$bHL@DR~CYF^?MqinP(oz}ZY`K!nj>tjCA#pB1y-ji)bJElw|Wd5;uG5eq_E(T;4 z5aq4}S*=V;X~lx##f`z?mw{&@0YznchmF1qeFTD-FhD(Z<3FChCaaNUrD4{V`FGE~ zAt4JyP4`yj?FkV;F?#(Nlxht*G=F83=M8>`Jep58(BZaDi3FkXlPyV2(fa)OflyW7 z@W8eC{glj>=o|*MIaG}o9{Nw^-C|>ZHPm2>Q9t@(KF+(8*)1}Li!DF#8MQqUB*5HN zxzQC&REEx)yfmS$W$Ko6ofzxb&->2p z^$v*!Hq{D)Vh*A4{F>5|QYYjvOlZ!>cD@`}i9BV0y3N6ZI1S8Bsq&PThwi(Z($?H@ zKHwbJ^O_S;szi}TM18xiKV0SU>I!l1%ly;5Z)`q+(|k440Fn(78RD5mVJ$d% zzCR@RxBsx&HyyxwVl7Jzi%nITd9O`lKIa_dLLFzwo0>o|k`^T#Rrmchzc|RNu)^{nGCL7TEx27HPSt(+qaNe2?4t zE8X7#j5*mP#(Rf_>(5Ri#|)rcfnS|xRFU_|r}=MQ%oy+Q_LtR@`7?R0Zkvq`DN@Ju z=LDGrKLf38c$ZANU*!`@<2|;Dt|U87)YgW1-=Oi8bZrZ}_ap6OWKgTxoDq#bpM^o5 zhr|iGA00z3NZ5EQbHVin8>U68p`=F}*nIC6n~bhsYAR{habDmY)oZQZKY5ML4CT=oREjakAd@?cL2d*Z8{rg9dJ|o4mN?8?W>uPx=kmh&b4gii8cMth? z=A#zC!Ek`a7om>J27JqE(F76*KtTg6PY4-;!Ydl}%}ZvB3sB?K%1a6K%cb|ra&E7c z8be0RWdv*5y}>jm!}!Ddy#=(dUHkA9Iu-jd-*vuw!%BEA#4sZ%(8qxWf_E~k8?iN3 z&}&4fW@+>&1+VwCdHR>37ngu^x=DII())z9>@Oj5U=7Mil6$0Ctp(P)Np1-^Az>kh zBr;K`?-eXKv;(9Ltc~9DJF@;I`=ZfRyVs zpO9nSS3DsRI1Ny!3L_c_%Zt+|w!Ykzi>HcB7qf;_zuFl(Ex|D5=4_y!PtZ2wb-}eR zbbwAO{GminL&80-_~N@aOGFlgSv1;R=&g{`t;571Q<&?uyFEx8Yx*$U%Buw~qY+WN z3^Q@*odc+6^CqkW;Qv2kaV zw`ydD%rV7BwNcD^hVjZ}Rfz62=1^k9G#K~320-5FzG%f_b!C|HA4LBh=3GSLDkIJH zc5q2UQ&fV9H7jdw?V+ppkj4laE+V`v!HuOW$?$$Tj)XV6e$*scR}4@>4kr{WEbJ^A zLECWl6F$GhwtS5y_S%vSeQ(OA{r^ zp6rC|TlU@key$ruHS;{bKi+@d&{=L|xm;(*cz~|))2Gc` z{LWZ5?x`3VytEW#)my@AH5lmdvhzd&s|zaehZE%9V-rF3)s050|D77gbvIRcih2lt z@f{@*pRE~VuP+>sbpS8OZU7bM@cL)yQ8Jo{bRVp%>-T|I#|aAvYmItargQMmlSYvB zKkSMN5NS{iPo;`M#ltWrLqnXKn;R{PdyjvE+RIUDBODZjJ+!?(qXb8cUM#R>^bu8* zx8f(p9c|EOn(~HU&kc)6-lj2HG?+e^pPx_Hjt_p}N_>kSddbb>JCt2coHGA%vqc$< z7(b9ez3TAL(443k$b;0~kXs zyq_K!^1K2|nnbTwrhrIn12y+1pQ@V)g{@oTizafto6n;$MT_C30HW?M>JPs@Et@eCJsUnw0JNNU3B?yZQ$@* zlGhpTAWQP#!3;=8g^9r%RfOOr{vl1M}C~|U59Z{ehrPH!s&xN+h%C$qG-034#$oDSJa!#wbxNCR`i(B66W?5=B0a1X%C<$_95WA+%=4@X z#0SXltDJNc%o>g3aqodN+nlUTupCkFhZ>l2{=vcS0ZrQMQ-?v?yNDJn+Q zS>mJ1q&@>C%JkV9$7>#!lq3|c4n`2uDfZkZoSHb1&^1f?I;?*na@i>SiPUHC4Wtxk zxA&AP?4~|Khtl^G_MsN9daddbYK8j40^^N(@G8$TwNrjnW$Po^bOw@Jiz6Iom@p@! z-qYXyZUsN{GA^6W*|UZ)o#WI~lm*b>Cxgq<{7HK|4-ef0BLA9F&xh^MlPtAcgp@qNP3r1CiHLyC)>O9$ z()+w@nSrEy>n@za9`0f5UIs3s4+spDQk`?E7O;`RM2C-2Mx&hd-a8LNMV%AR%{zm| z20~yyOkDm^bC9`XoGPd7oWITm2S8?%JS7~@wBVSKlnMGbNWU`YYy6V zhRGC)5mS`qS?$()RgG`uH6J_P`7dAm7{2%e(JS`Ue&tW@A;pxzVDVFwumMKStHLp+ zGD9(s86z#5;#3|EOQW_1`bX6DKiH0pg*m2LkGtcK1x@2xCn?X*zG%B&?Z)NEIYDv9 z#}W1`$~^dAmVwOlGT59pDb8|Z)dRs}H(CGn#yL)RGTE9pL>*`+bl(Qe3sYrq757ZE z+#mNJsjl9$C+Vyn+0y(oIwnzC67q_T-%WL@4Qn~U^Qy2R_k3-QNnfOG$<{E3XK84KtZPW z_}b?be5Urr7qYW?PhTHsa@J^bc{!-V5DXa35ek<@ zL=YYeF*=yqyCc`n`RS&vbM_b1+bcrngEz1P_;#yb%el?+Z*w03ZAH)H?zLRy<++Y# z=pQoS%X;b3r3hpuV*GsFjgOxn5yLGg`% zEZom13YXSJ3W=yzp!*;dn{s{%t{UJ;<{|cv&I+(eQv&^R#W6YMhRCX*88Z#N_+@`XQF8Fs>bj>t z|MOl}aFm>R9of8JFxQP~!h7Ky~Ir3%UY&w|$R~4-&!i?(0yFOoEGj$D1D( z%`m`&LzyTn;7B5Yge>3)N~7lF78NVj@PjvbU~xU!J=xVw21Eg{Jy32au1yXM)CT_n z$N1P@4zrGx~vFSU{~KF`opC zR$-K)=Ob{+$fyf?_cNY)>jO(FA2a=-be3(`l_KK9;g?VHfI9nBDwkh>p0)Vlzn!4( zr}y-N1ow@-7g6Qo!<_Fp^3tPq=-^5Q2I_nUgkW6HUqzlKB=9HOfX?gI_#ZGNoWMLy zLGS6sH0g~t95)_Wp|;S{H?fQLIAQ=|+(W(zv_l5PPpOW+<-LEy31SEALWu8>a=w%* zH^xB)571Yzd}BSX+S{n@DDe@qksC<& z;GDW*HFw{<+c}?P=tuXs9IQz)(P_DIVavaL-07{A%sj43%!ev~Y%ivw7_ed-PzXcX ziZTxXWhDF;$jNJ7Xu1MY)a-IG51i$H} z@O+Ht9%gSsxV8L4zU-5W{<$3U3z%~_C6CJfs(I~WI%O;!RPHNC=sF-k2;&5aiHPWe z4SF)1%FgICH8x^=!!;OoJr^NFWC&Wj2y-{$S8$@HB_M&p6XuXq_$qc@++q3|+7By`!ihYK7vWz3!{N!~5Ez385pUPq$V%Udv*qAaQl^4Qah z2watLwg0lYkkV22Kz(Av~>&CG(%>4dI7Z)D_y=k(&mivUHS&xi+eYN_wU*I5%A zT~S-7;<9}b*jPscqg`OC2v_cIyFm~ie#07in&1aU&eWk9_RVp-pB%R)IgtGJzv8j! z@w(#mnUjda!g1tuh#-=oQ}m$Ix?*4eGp}HRah1?`n(=JE&8bmz zW~2yZYS6fEX#&fhCt;~;W|?kH_1p#DHa61*R&YIjyrnA_-erY`B_~hnrRwwui}OR6 zU75zK&Y$H@ih(js48tzJ>CGpGp^nG)sn(oH&!s`0D`BeIJXb9-z@nTQbD})Wr|>VC z(q!reVVTx z%GXe}V%oy??W{)QKwMGgS84u7rL9y? zD>!O*4z7zh8D)Kvd>!iFqy9tC>_=_u|6d+ss5U)3qL#-RyYC3q^?TQ{Li6!b4wIWE zCw;AlCi1oO%_U0BI&^)Uqr1xF1?BGCy^;>|962q~!@v2i`FfdgmrU-Ek~9li*-O|c zk{JRwP2QdC;Nd^2saXq-Wq5cvhRxVM+7r^74-B|3SRSGA$Eq zVPfZJzZp&QYSLNJ{?~cUoypz>ojVIPcMAx>gsTD8mH5PEJV|?3iH4MdD;3rURmd zlHhzU4kpnRi^O3|Ju?dW97rWOm7DJ|8~1COnFSTj%!14t3jzyDv}|<`=pGK+OH z7OmA_tncl-PfEx70cp|&+p$_eTI3%LC#UPVQM_vp@d})7U4}HPZ~#hT%*Aj@fbK*hMvx=Te(dh5cp7-MF%AN>?!+f1 zvhMafqJwJv1t5rLG$Dq5u&a6u6a%|}MT{KVx4+HH18jOZoL{Opg}o~%WQ|+U-zZ~b zc;aCj5jB|$13-X`h&#|!(~Q$SI$i?&-T8BdaXF50)I3ouxHcB_#&lY=xiv0A0`&#E zN%iQ{`_;hB*6u%o;l)~1ZMloU_FCmBUD5%e*Lsvjgj0;1w3mJ)#;as+ux&)(G9xp- zfZe}Jgw+VKR;2K+^c)^cOkdZ<{94qekHoF0r6U@oNTF~?UB)PM@BYD}a9Gqbft6w|MF*62bEl2YcFe5A`1vi>ns>CsFtzSpjdG;zP=$Fe*?JhmnI7 z?@Mi`3kO0uM!y^Sv`L;7d>DM`rplMekIO>EA4r-BUJ%2Ow{sdVF#ROk13X!3qcaMZ z-N{Ly`oeQ<|MuNhDBdHU2p-P}Kg)GZSHm83ov50!veh5JV`36Dg~QzSTs-HSn|88G zMN+^oPVgDZtmaeBDB=@Tj&=n6DkathGSIE{Ioa7VppL0zZ#+@GVKA`A-7xD|@HXYU zQA|dqG0Qv{$Retp984sd2aul0_0f5Kh&3+d(rELccn=%C${s1E8UY*LTn0i<`)>4e z!tk(tz?E%@y79F*p8!aHr_Q17*f-<_RcBf+XP#G(MZ$Km0JNUU;(~%G&=fI!m)^V! zt&$6e^vx4ApnPlPV?y|H6qC*jOYF`4M{I0t%BsL*J-y!KCCYsGROjQ1?g99XCBl-f zS7iUVf`uz!xpf4_Sy`IfDpI0i8`lLmkO!>5o;pkf6B3$O<+rO68bWwE6_)?c^P$3& zFY}>54}UF&mrd{h%NVEIjt1(ynNeKDgPCn-Y9_S#kAEiLncSL&_|}amE?p;VibpOw zx68->c9}%T5*G}np6?(B81C_`W+c+3V1Q7#2Jd<>lo>Y3n|eIl*cjipzs#{3eQ=2cW=Nkx=1uC0!jg z(8r1}OddkMAtHvw_;y>iz|L1+kfAoTg^q}_VPXxZ^O{R-!_>UfRd~ zq3waShk5s7*ah~i*l#bcA;k|oT(#)?d?o=UueGTUQ~$)OJveR(Z;t)z)jT{K>HfIZ zhJ!>VJ4)G%yw!X?m;wnEh6j1Mxt-7@sg=V{*pz~?4MsNgl$Z&KrQt<^#a8zT2OV0A z*ORBk)gYUKX-6?GDv%qVQz^sR>*pQlavt*LU)u3kAE+f-_ zZdel5UFcPXDXd4nRnX8Pz+fa&U}ZIiEvvY~?-tm@8PBGk-WR4xsuo2UQEf5Q67dnv z=>2)$vlCe4;L}R-`mbCN6B}Fn_?DjM>}iwk>hj^M@oQTS`?Lq}BVgVK#&%`&cIL00 z?2j|3a6kL_$I?ibbG#D_*2L0}-QoUld+V5+<)$;lUvxGOAKwwP}D;fgKs_6;Y3?@hXyjeTaOpXTvh{c9Dc&d;KVC zrFRAfbLL62s~KY_0{^z`040W8$N|X|;!C)~k&KVnO(J6h$}*A8+6Wf303V-{sw$yK z^Xb9Nq*j9f{>k}j&#A%&&b#-l-mDW8|#=U?`{$>-E_|97bXP{B>9ep3n zjqFKmAb7J;U>YLlpLZFo&y5}N%geU;T8`smJ_;Ci;hp_)@#|}S(z93Af5VgK42beZ z+)o3ggnB{u?bo^X(jOvfgg>aym^P zywkQVeAlWFBR+g8|d72QS~G)uK&E|*u&$C{{`?F zoYP!+Rr2h&v6+O8>}gMM0KfuyPQ?Pmg^x`LKbVK=?SG2({`OZ)8EEo=f8OATsY5tn-sjN-M{hInzgl`80373mm^ zgiTZNNh*oP=Jtl{II*u`a@DapuWV^<>x%o<*(vW5fl z#VJ;B#Q}ZlwCWPN6{F?abCy0$#X$fQk2ZlWlF8@^Tdi40TfO$;BL6Ze@Rm$P$M>kn z_`%}{kVx_*Nd2@BLzqt@i^O|Rl8lOhzZ_yXV%|38`=(=F>pD`L#q8V0L~pY~L8E+#!uCO8&28eh}mG82%VLKpgDhl2QsS8fi z%Ltg5m~6U;FSAOd4m7iD7f7Q6`>pZLp zY!?b((M4l~4l?LzF#D&{>>VThxM?5>R%tDI9G#0!fXWU>6dW?i{M)hfvAmU5eE;Xx zZQCj!JiWJ+?cOej>=aOhZyp4UI!?egLF3=vN-nysFmP*;z-@1OmDA0cYmTX@t+uJ` zRfTx(IfS;&5W=OW$Hkdq&cVh!=Q9+D&EZO!j6H@N@Oh)#$>on&1_-f}a&|LE+dg7pI=;uNysT^(b!pYbtaUQ^3Zs=OK#`M&;T4B(@-hz&`dVnA zxGp#s3@hb7FfNAqQzsTpl`Umr(QOX|ERsdjT|@kv5pEzlsf(%?b`Aiz7@k*5fq+_W zKm7|BYCUJd;Kj`I$}d1JzoW+NQS?qN3LaT7b<$B~wltxD50dw`%w7Z_`oNI*XiES| zMhqL+0FGXBzOUvSo_FhrjBRDt&Bzn+p--M{1vre-0Z>`iXs2=ApWQvM$Vg=Q6cqX=O3Q5AEpfXP}DmYzin=a7P=>DJZQ_($ly4ghQ zNZciSYo5@FyItBZ%-OowTIA}WfyTe>f9BIvpbY`;^#z4N2SaO~b(EJlKbF1Z#v`Y! z_s1s=)y@9&Qv>Oz^xx!ZP02Q5uTNKwPg58?C&yAxZnvpU4d=UBUR>=aW?h)mxJ@oj zs1YdI)Pe9_ei(JIYy6?*efrg^FZ_|P6b1(;eo@7N*Jtx1(xSroFeF!talaqRH;?9@ zdzfTlim$M!@4i4{F({~VlO-pTlZCm+SHQ#ladg>ue zu4lilMG$ax!=|gW>qZOR;-z`AyK7hSKaaop5Ik-VuldDgQPlLF+}^ z%C;#|$B$^nL^kteHf8Vcq#nf%AyeI&gr^JEFb}LGXXkhJrG&ZhMv=_6|B*sY{8gy{ zXwDU>DYn3T(5$~Yf6Sd--+k|SYoB2v&UK5GTfv+5|< zVH5d>lEH$fybH|Mi9FQN4i`zFmoMI=U2_3OJjc}N=*yaE@A}P~whx~Ixyi9I_v$yx zsv-a(30n5tMeBMU`gLrh9fA6Q8I2kVjH&p56J_x?ofCY zf5EMN88xcQ%F4i2Lt7<~o1ZT|2Lk-KyK?A#^QmaZ2r+@Yyg7{RSM#-XiC;0X%b22x z#(922Z68Z%X+!$X!Hv!+mK_Gb;1=+uj5mWH?&9ewGUW?q)$cB@d&hAy6vJiEw+VQJ z*5c;Po7CuYcJo|p_{faeT_=n(hq#Ugh&0%qLY08gB&1))&PvN}ii_)I6m=*JZyKlE z@qRupA|{#fK)U45D_Z6qUGeMIyT2LzAstryA)&Zf=vR(I7dbx&KOQ{O@$_9>{L)({ z=eolF3jVNxN9os)KW|C@uC~{z?nTI6Gls5x5y4M?>s~9l;o}1S<)4=_`mMRQd{NEr zJ!xYdT^&3>{k$-}wxczCphG88NA&#D<`JHJR!U|Y#*h;31dv2a24*2)BCd5zgZ1WV z!A=hyADPiQ2*9A=ZX;|x5e@K&bIyqvmpW?hZv;;rRBXE?8s*(L2sypxQj6m1Hsd{} zn@rixJN@~tnsz-r%Zw!ed;pQy0e2>xCp)U8bkZU%93V)p$fVWr$=wiVa_F+_d->rj zScfn@>>$hJ@&$Mn!*l$0;%YmmR2TjJ1Iypqusql#N`1O!`7q{D~zc zz??snmV#fu!^-o$G+4BNQNR_fPhwR(l%kvLbhgD9H$Eo_l^aVAan1nUxfcns*Lz|2 zwBlrC{CU+PI>}gk9m27;+v>P?j9lB!V)}8|*)DEKGANHZvL4aM{mReOpExlKMAQWu zJFaz+3v4(43{?{XZutcSco5oWWrn6Q(6i;;(EXT<<2t-Kj>|3MDS=O}2D=*6lZ@-q zWqm$s6OeAPthQp^iU=DL(>7t;oOkCpwaYaMdIp;~hBtuZ$QX)(d+7E2 zj)E@{uHg4X$@*oS2BD9VD`@r-OqrBPYgBasp%nb!&Q287o zt_zLEn@2+toT~&eu(RpYC(h7rqS(6c<^~grb6qFv_ZORSN;*JiaEzb0iXO2cJcSVe zkA7CL&)bQby1H_x|1oKK8B+S2DEhhRm04<56`1swfa7|rhy^@N^7rTR?x7n()^L}m z(Isv6lrHTgb6X-codz6v8$y;>Bzldc6)E%PpIgwiHLIOXg_pAs*<7qj6mWo%vBK=P z1MHgc^PA4=%zR0guYAOh{W8-NHB)Pt!@$1K==}McEKsJDgWmxVnR690o>kK*Ywl~H zIT>ri2Eb2UQCqpQReWCjjGF&-Wf~B{XS0u1~ z5G*2z0dxtvhK3UmN$)b<)NcJa?3~Z^{bf?%ch+*WKfbU8iVph$mc@AM++_Y3bGEGV zJ>Ig~>k?a}_4GmnzVr-x9lm~HL`Zr-0GeW1X_z>H=?bi0)P`%-IH9FjwGLhywolSP zBJbhVdH3K~r^6n*8Zmo^MKoI5`V0J=C?DdNrbIm_Sm>gD!LT7%&2L`JxdodP(kDlw9a}K7}(7qTy!B!qt zHyDs~Gx39se{79vj($@)$*gOrULwJ2^75u>r*_0SHGedwKnS*Tb8~OoMkEC6C$poz zUbOXzA8|N1t{FP_rQvOfl9%I;K09z=lT*nbTS)FJRY~Y6zrGE70z7BP2H3NS?Cx-9 z;N(Oo(1ooA7)l6}q#i(ondt^3;64m`f}EU}*%+dMgyl$iWkYOyHPG#&Qsbd2N2kYJx1wMScY#w(+4NYuewAUoQ7X~{d; ze#B?qmZ!DL94vl%JKQ_lnqS1qacRSNNQRCLBu?)|LkEkjsSy9Gn z1q2<}W9g}JnnMe8u%vHuc_y(GMu!|97l+4_AeG!)8p(08P83xbV-fuEgVuuU`FK+ zP-ThR*J$Ou>?5#sIkj)#rQNOHq}k+fuMM>4HgmJY4;o zLuEO*TW~2qcFg7Am+j`7lk@au+cFAQE8_1E9^ClApx$azd1iT6rBFV1VGWKCLKXUI zr1|K~X23b*m7GRAhDZ*Ukrs5%)K3_SItHl+Ctc3FL(Dp{#)=8j1OXpPN)xWkm&95RfwBxbKG5 z%0*07Ovj<>N`emVC5$=XAbC zShqa*@otsJ()RM@OVly1!+gi8nlbOd*ER(oirC+RGv5I+JI;LAl^eZVU>Luw9#E~OM7W)2kwK9I6VVkq7 z^#%@6taWlfbgy6nfR0;UMwmDXJ9E%1zU7NPHI>m5b3tz$+B8##i`-xL$s!Wu;hif!i=BWhisxP;$ZK#8?BPaLGk^*N6+j zdWmP7 zPo9F*0hJVBo*z(Ik}z9yAfz_bo2=+_1Q4Ta0Zy-$VK^u41d$W7(UkCT1DuQ;Jj6NS zzNFuyM;k&=+uBIfN5TxJ8~sG)pRJ>J%Oti2rWA)axcI5IRvf<4JybPr>r-TRX>GGq z>!|r56Ia%9shoyjp=+JZsn%xDJZcsF@E-i&Q(p!U2}VMF36?aEMshzM(X2ZxbQI6s zH>Hv<6gU2~m4~MmP-?Qg5862JNx>G7zT$|2>B-u~e!enXQwd#hloAw`k;o{+&?Jj4 zs9jQ**T#_qVo*P~Qtsni*CCq`ol);1J9#}X-XXGzV29s#NLPFVNB4FO5yZjv$lH$? zig$O0fvSR^=6`Vg_vhJxm_;dD1uxZRbd!K=$LR1M8H%tdf#Q&CwuJB+-OT6{ zQ)dyr=?4Ac5lP}yhb7xx5x+eY6W}z=m9QC+x)OPWs7*-_*FzG-RUX?6Q8!7pU8f0F z$}=PQfx-DF5tC_G_XoSrt6+u+cNiP+ba3`=j<2eyu!IE(jEw}nc3>(Jc!6%M;0`oS zNQ+fv8eLv7jtGr4HtQou1UnuhGpl={NpT*%H987cKh6HuS?jNMfH9tHJui^?g=r^r z#K3PxZn#LWD)A<2!gB|Y^ZWOooEPX`=q`M%+})`sW>fWT(X#6=rYQx}$J0c6XVvFf zyC+}7W0&0g&5O2QfkDvgc^ka(cR6f~%Ed2OTQ@szIxtf@ZrDQkZOS+4qZMoU&oo}Y zDpN)%OgDU|Tl3bfiD#tq+Wohb?)hV#1vH;+gJ`JPfTw<;dBNRy`UBR*xf-F| zzs|(}mi@cKPsER5@B7zB&ZN<_{7g}yl*+nI@*mlaeC^UA3SH{l-^8DhoXLXs&JD3 z{R1AFWG61lz@&~%V*g2IO`B*f->>G+Qi{Yy{g+3pdR^+TE)tOFq zO~7r1uBwZVLS&{amWgQ#^@`aoodFkS?gUWKda^3!zFzE?nr^zk{{FR_e`$Y%Nz>3U zI{)xSGLuU%ZE!+lM7U5dxa~}y{IK-CO|JCUOxD; z@a}F}GK(HNbv$wZ@E*=~hlDgIVVe*N9%y-7}_L1@? zX~J49c^w8mE=gjse;T_Pu?}5)-Wud7A;kGxq{w%>wfw<%SU2Rw=tz9m{QWm(EYkTz z*P2h!rUw>6+|~hRg5#C1L?_~A;P2v)JYO6rlS?0GIhD_qtToT!Y=`%Mrrrha`C52J zsV_!5009WH8G`yH-#AZ5hQDycjYo66r66{D-m)g4r1riY<(YPA{CW9C>}DT{5~yV5 zb`^`KU+Ol~2h+Fs#eUSF%V6q@8q#kZg=kN^CdCpmAK1d8;2Z;8-ljL5@<^0>R2j)p zBpVo>n*GQS_Vm*ZCL{T`=vQ1h+uZ%#XHxR(z4s2JeAK?&j ztFtI#sbg1B@DltrnGe~uI5uJ7AwKfSrB1rP1n$Hcdii_$4j0iu`(O#I3JQDPs#f%= z$EXO+__l3ex;#VItKNbJ)f&wGdM*cUJ3rbiNdXC+m?ptDRh=J!CDXB+wtpbR9g2gmg%~=e5=#Fibdi)h>QG_sp%p_BPbieHClT9BQal$yI0Ir2%kv1 z;+G5^wdvx*PvOy|l=1gxW&7Fa)son?p;}B=e|SObFnR{gs;k7!#f2m@RG)_W`V}bL zFboc8=ro!4&RaxaE#zW3C_LB|eAecW5S#uI{0Rv&I32*72@0L1wQRc;H8i|^eMu~i zh0$NGD|jA8oM967!ZnKt0qW<_ONQ*I*U2(Mkg(U|lo0GgJ zj@c*Brf~FCys|$ps4_03}_8y zPLmk>DL##?U8aIyZ=O8{p2WgNP)xSFg4MM)he+M}m7yDa0-hdUs{_F2qq|4XOj-#4 znK_Li17P8CMZ}O~5;2%lt-Iux48p~eH7DNeESE|)Vi&d@D}>v=92UNGXx)KwiPJ8TDVwJSD4@^ zCOLvC&#ww-MoSnmVO9cD#U*0p?iKN{n#kAbG?@mDi??{9hSKTiHiNa!Xei3^voVeq zY&W+)-#0E#@7ei6BUo1XuF^91^R*7@Xd2oFiBB0!HjojPPOgwgkJ7@b8TW`hN%fj^lYjPy`N?Z6>n(?ePuzl4M2JPRL0YBKJgxX6yIfYSF=hd>` zpXcoq-Sn=S?mh#jwL&r0LwOc4 zj!R|v-bp->_ZHqAPC23mS>c>-zyBbbp46W2+O3uy-&8B+r7Uge{UM^_I7QR64^I)u9&vK|my-iihF=c^VlhjvNhY zbA5-RH4Z;M?D?1*5Wvqb-_cguH{wjI(;cYUl)x9f0+k`5Zbyd{FFethveaS80XPJNI7Ijf~uT->UFgkMu=SnFU^m9sNe zGTDe!-b%#R!+5N)>eb2f;)S?&XL0SsCi;tex_1j>B_q+sZBW9;+4mGxkuH`hy=L zH8sJZQBxxZo=3{S;Wf2FSR2p-N89pA94e|*aKX+6Cia9QL%BKe5q!PpARJ??$qQC6 zeBBQ$ptqd_i^ze+z6}D0;sK$aanp6Om&Xy%`5X+fj2_HNpv~RddZ8EGv`Ipr%9p_= zd=V zc(x{j#D4c`@Bc+S_0`g;dZi#923=Gbm39t{YI5IT zWA^_IN1Zu&2#hiHYg2o*ZOzKHc~=Bfo}L(hX4?{9!)=-)E-fA(p{gC~IKYo0Iqy=ym*1W#5z`@ZToojfAI&kw19Er1RWTc0yAj(PcF zcSmk88pSy7(Cl%c^srERG${j`mG1dTyt|}y^u6*stYE4e3p?wOknFJeQHC_rN6S=G z<9bsrc=FgAkIVAB^=e#G;5C(m(K?Q;FM>!VV<-2ZK4|t_B2X?>Z#4kQEdUGa5ihzE zS@tdC@4mvSCG*PoX{Konq`1GPk@M;Ypv5Q*K`jQ$bz&#gV;tq%6~?sb9n;&c`?(ox zBveR}6@F^nHN*Kcv*&5(*nK$JrK$N{i{O5_6ZDYh}*1%8D!{0V1L$K;{&QC z!c>QXrLYG>%gw7ujy=q?5Qc8mxLq?OB*f7vF+QGXNl^J>j#5j)(Qx**3?t$DQngvB zolAxSjLshPuD}A=#?4J@{Tw!@usCxweTBqr3)n0Hcu2wwgt%@7UOh?8@Q?BSzZfuWuAbQn2MDy~SI2tUu(_&`S&ml(J!hpHFDV!ww;6Q<`c;Et0yavO~S^UNH1syJvoz3H3I zvTIaQLru*Bh>67jXvtOZkH7ZcOl znOIB9rGQherxJudxTx!b=j<`|nTOKLQ@8~>q^lXr1c2x*ZE)$eG122~6zb8YmpS93 zCVzQTo!15H_LCUnA6JkqZ+7*5XC)HM_f)1+M}zlxskWjSuDhShRfWagAlNS8P4qSD zM3r7#_oHPOga-7-btMWK)NTHlzFfdO&?#q~H2*~$RZrGd+@TcOV82Husgey4A$TOL zxli)s@;n7#N+G5}k^B?Y@zE<_O5#o^B-g`*P8qu2NmZg)L;XQ)_lN&BwO(lWn&;Pt zId@H18;1i^YSn>T%0Gw<&t@yCmKJQrf%~>G&eb~4xS`dk*)?dlWQ?QhM0PTpODLWz zNAJVx+c+u}5(lxHe0Qts8NSR(R23C_;b-wG>|~yLtzDc>oj{~ zbMwJ0k-G*P8yA6Z8vQc>%$rW>fYw3zeg>u|V1Istvb()2Q12fVVt}ar-U-@+_3WD` zL&24Xx1a2u2N86RT-PPAhK0qLEbm9;nl@|8@jG`NkQhO?mgMDjWraz18;9I^r_$T1 z3MTI=dF}`5C&wpZ2aU2w**LS=(s1+7?6w-UNP3BecbJ74>IqqQZc&sVzymWV=W;hX zcU79&mLb784MPD_Zy}6qfhdFXf)7cqybyzgxXTId?c2k^Eun;g2t-hC(s9fH-I#?c zTnRp&#UAqTj3-Ba_Cte>)wefmY{@FFF9Bj`O79vGMqN_PyX-l#_RK)9TMBJL0>cwD zuTG<{UoGfgkPVFbJs!2gLNE<%*ZPuq^Zx^}OVH!Tjoz1QlE5?1M}3E<-wC4wv>^E3 zRgC%ILf(BK3qEE9sHcrUv4LgA!LyUfy0&|X{J{tBdojS+wtc%~qBuqfn5(ndz<(TC zg-Kw4+FcotH#E2}WS;`S36MN<{qIHGYtZY_#|%tRs8L_0wB*>s4}pWRPFl%36-x6U zU6Rll^x*>=fh5eBjJ1BKK~d(;b+T#Yms86)`s=(5kt-FLxuM|QUxIlQ8ca+{tkKed zo+APv8IZR`EGiE$4%Q;yrXv1)Km+nK7M3Ef=o#i!81J~wY?fWvQ1gMV0*VfFLzUj0 zPMJih=s*wf(xtx>UFm^at9u~c*(S*%;Ynx~8TlE?=kk5Lzd*{x-Y&b*S~1HcA94zQ z6$U9|864{^4{3-#)|1m0Z(kk6-1jZO>Ko&N-VKj0u$5C(6jZ%saJ`}WK#fmRL)Nli z$UXc-V5#qSg(k110|2*siSBVvw{1?7Dn=&ERFgylDZr*FH*V!qaNEasCADqm4J2 zeJcBm`g$7bH_&`cFQ13eiuA#60Di+K`2?4p`TI^R6!Ay^u4|G8HPfGU)s9B04d$QJ ztiAH-uNhO`=L{9!woJ3;xCQ&AM%Z5JeD2FvPJ5O%oOJd+kCBB@{`1_dJ2MuRk+nd<&`$T07oEna`e z%GPJIS*q}KC61Tt*_N9F&-{9QFYc85vXQ_;jd6KCU!h^^h3Bd~lcV-#Jp3KJoaDb3 z_{;$<)jv;8OiZ|QYIr4TA5Cw}mmhnZFkepYH5C9YnpyhPH@>!{(v4!7KGf}IW1W%A z$UMkIE%e?TnuCmT|915bzgUt<*6%@V$g9Iks0~f|NtFwHa|@@P1lOGlUt(FDgfbYEN8A;PB=4-;@U%H-1wdP=V*U zA23Qb=`YiN2AaSBfOGB%^(q?o-2F;+qW%sLXY#YBe?9YmQ;73N1EbyY!s25p%E~K8 z2`R!iggM(dubkOIes$fkRH>%E{N^pr4RYR(Pd+>Hn#=8D$G}tTF~@^O$*n<0XzxT_ zrrz_J_uQLh+NK$9ID;eo{p$0PDs;fuv3$L%j%L>pDVG}CgyCsj-LgBz7I#x4{#GHTc}wF*ewqa^Pt5>?A04BdHvBTpH63xN z2d)C(fcSvqH#4gvWfDv-Gyon|s@^wEX_~cS9Q%x2llLZW@st+6)NotCz72l5DgXLI z_D(&6fAWqWF(`{-U3HUaDO}*&@|mPe5OxTPkdta^f;dc|6xvz5{;_wV}!5}M)^-^BwcF6K$)fMEEF1k;+^@&5$OcU8Yn%I3+|=e$icyhb9o74 zGP+z%Fo!JO`hnnBXVG$?TC_5E3$9ik$wMSBBJUT1pP_CmS7#Mtr3 zGP;=3-DOl8JwJZUSq>x9%$)_(vjt;&mK)PVR$rstD<;KKz-DztZ&CUFeH?%`6e`U{ z6_9O>67eJ|$xvcp%GXQ$p}>?v$jIKUBxJ4U1mT?xZxi12Bi5^_@dr9tbbW_kU9wjB=Gh>we}>}HM&N%3gOLEeT?uJ%u2)#kriQH5=`-cN zDBNJ4;OlLwx1osZE2Dyg>FA; z0vJEM4>=}Yt-pEP+5*KY3KbG!fs$eE$+00@$`B?4qyj<7TIh^%!}b5vTDCtu0;YrU zR)Q2cmZDs-9dE`1UiSunRx%0oxod`VOBhHeM}QT#kgE$8(9O_7-=`%N zX__c5;z{IMkN<`T`iyNPYV*#>m5#d(eWA|^@vXK-BFf!eXis!N%rv17Xu{ zb)dgDq|Cm5Xc!1_B4yiIar&^jOPynPWn&yot0&?4%!h#!rt%J2xAJZUOX${K!kk4_ zuJkc&?NO&vx$tujT7E!~UBWRBNP+d?mk!Wrp38yY`e=hOXA{ z`(>8<&eZJI%jp3t98({`U>&w(XyUDtONPhN=`MUB(F&4Gx22s#3Xi4-K^J78RNn*X z{^v?;(vQnN#ErH0f5)$N?Cb!sptYF_|1(Goz>Gw%)}p@>pmAWw-u}yz(_Gt}w6oF* z%efhIaIKv}(USz6Jlk^M!&uIaJf=LNYQP`>EiMzA@C^L> zZVLXjbDaji>wmB+TDGAVtbu3Mtk=2?I82S%mND(phB%LsS|U9>_L)E(`EZ{H5Bep= z{!eH_m?}6pIhQu?fzOIe7L=jPPT1Ug0EfW8tj>NJGs0OaCr>z|SGm3kL}aKJ-ykwh zQfPe7Xd6jAdO`rokT4Lv!ONwfUMD78ne;1|eW5ko>d;Q8`Y;xTors*U4zNo<3jE7C zY<*Zo{|1>%@(Mgj{N|&HCt?>Tk99?6SK;a62exdaee{h38)}Gev*ZXmCr5$%18GIK zO>(d;A#G5&CUpbDF7_1ZQu!u*dq=RNLDW?P)Q>I)5AU93hwT6LlIi$dVM|_j0V>z8 zuWPiVC=oo9U{t;4<||m=iH{FapBLCse*^ahi5?Q^iNqLnE-G80xxM!*`g_yO-JKZB z`<#CSh2Ej?yQZ&593Ra85ubEf(Dv^?e&M*V*#ho#5r}v%_#KH!TNWwG6&_i3`O~WKe8=U{sKVgA3I z?W8nR%KJ(KepTDKUr|Wlxqlycp+6+b*8Zn$kJs}h?NdZq5{zs3WT)mV!{uqptQNY6 zIn$|Gt=0zkK+RBQXU@J1x>soTVUlO7FLJMpsnW&k@1VSi16(Dk#LN!uvD;Rf1mf>} z=1J~sf9Gi$;micXE8D?8IyxHmsn~?X;@4##2ZFUv+xtybwreeDoYPS6wqIWSOBEY* zjIz+=ZQJbwYr}q#rA;1`JgT8#4WY=n+CRpv*Vx74K5%yV3U9x?KY0n@X`@#+R;qCP z+o#(}Is&hKgi{xrFXm~zWMg@zp1N*N{j1usnjud9*(;7_iglY8h;13Dt;i`uJv}lG zU&(GK@x#d`QN_1dfP#Q%hdn5YA%vS4&Js5=Fu3I_ z4&D|p2FPHxTrgTu(V^B5i4%b2y~)#xABWazY|us6w|9LdMIS01_&Z>2m&3Q;1hrP6 zyv=w|7HDlOyR_c`X27#_hs~PASK;B!#iTwI_9j;WR;~I%HKu--f}PaIMiJKdpgcB| z02aT4J+)g#pvpJ{;LdRCs?CF!J?|dzMv@~$UohNaLBua;3+D@a2v-cO3@f;y5afpd zt$e@E{=IN-A@joF5!kStYRi=i)us=AKTNVqP#Kq$mIhagPgpk>SDz_TsBeas zZ|a1gI^emw#5tT)g+1Kgw1mdOb2#OuoeutifuF#>y`c(zECkm9m$qJSoBC9WDi~3o zSwyt;;&p%^Ljx zUzYM5zAipbJq8wPG1@XI*po0xXTE=4U*8@P+xJmKP8uD&$HpQBekAU(V#jZ%^@esN zu|q**@d=*~z5>iUe%m`1c~984YrPBi8gX`p`sPfLW6-_WR8Fd*7)yo66+yo&tnjQL zCOGcUyeO)@x};$m&DWlPng~Eh?qtvTcvy@9<3b`=NfAcoHG$$q^?>-39>g>Suscvl zO6n#V7rN8f6Y84l-eKgMI@07xf?kCgZpkW&8d$^lL3XeMaA*?>j!44s?h0rqv?GbOh zAOd&SmD_O)VRb-~mn`SWAD=FzNL_jRk@%ntaXx(Uz?Iekm089cBoNY@gHp3r9+9W= zMTwwTuikk&lO$0bi3kfrz%5R;nE&azKiWk1>L5$vWS4g$9gE0^$*_a#uY5v#9kg@& zIhSh3EyLuOWpLIgzWdW}+lg%7zQSq-{{sbaYACjN9bq$mVYz;ku-$s9rq;MY*7n)y z#$NCN(|*q}d%N-`)6X>2mWqF&{hGdCzS>9iW7>58i4_P*dbL)WDDIH z0E1T=3Me{OG~`5E9>45+;@@)yZuL1&t-2cBt1R{kmDI%MCU?zbQ*h@g6BX-AWIsXf zfknFa{SQ#E4#j7WrZqfwizTC*1MW;_F`tZ7OG>#qP+yaNa$D`sG^jMDDWl&J8Giv_ zNC?`09<$ggCRUgLz*r!CylCZicL2tjd*_kH=7+i(a}IUm=^~-!4fW?Xb(Pr+m+d_B z3psNCvHocNzu=GmALoM#-tjl@2<+?Q#u!qf)(A$Myk?u2@>)L&YOiaXiz%LcW>;KZ zaB6GADp}*f(GO`8nVU$jven^WS>@`FhwbJtPq8Kn=IXixhmMXBUmxT{f7+ABiI{j( zNa%>)3H5U{^Jrh=T`>oujgqaC zC=H>!Hu~}?fSF7t-2Q`<{JaSo)#KHU8R@J)Ei&EbO0)mx74Kw^=(QV!nJ0_flspL?-`^SgHMZa-V5|vhIde1*=p}?Qb)(d%CMy-G z0DJ9A6w50dGhx>vf7&Cso_$D&QCieE)-j^{G51}aScU38KAfZ1I~W0Bkeh4JHvJWBcTCM7cfVK2>@u3;~>+&Vz{Tf zNq7=HwJ2DkXi14o6L>4}2r%9}aF1BA!$T0}+Y1lkWR^6qdPW$XVNae|z+vpAbIT`` zWezUjlRi{wz^Oq@>7j78@ACyGAnt+-*R3m`s+{QOtx0Bf5Ndz(5nE%I=}$XxAmd;)2&P$ zsZve&kIs3;UCcRprRyHwr|0J3;#%g-^q+}WjGA5$Er1D`!H|p$b$=l@!$)92t(IRR z8HKXGB@U#C?uT@F+xjPfvy4IP6aFichKD6H~_o@XIRG6&|AA| zptRZg3AEo)psOT>?XC@&y^8IlO##v3z3kDBBFSoV`+T9v-Cxn8Ut=A08dWgFm)GnqGvZ`&Nn{qQHHcKN=}aHoU`>UWX-gdW$lB-*n8ckg)hPJ{ zlzVIwF1`>vTArP-Oe#TU^)aOd`ZDJL14^31i2hX4mm_m+;&hp68VQykm{o`!V9yDf z6te;Xjs*hSH(B^R`8=Kg+|~l51Y|9M3M}iG1dP%zM=2!T6sKEMR} zTE!zrjzBC8#p9r|KA~r`Ru>hmC;}DiM%v;MyAF_3{0+9U=I64->=q%03NKZ0@eC>h zt-7|K`SZ*}hQ~a}%}B^SUwV!nw~N&pl)(4ruIsr~+%lL?#Vh+UAwKpz5wM2M#>SM8 zJqKfXl}&?c2*oaJZqq%&#zRP zux!MD-}z~II`UlrTk}CsS8%(~Bm3#{3b(bkn&mO&58u~`_1}xBZJu}Ch zH003(BnhptaUi&+fh4xC2*7qoV+SOeox~bqEAt9g!Vpa0+LLUK!4DJj@`=232>oGJ zq5eAIm4U+Y&?T*8dQTjB_gprA^PvMhsY3^WB%znYwdV+866)R_4fl@QEDTuQS@IBrZ~(@cSG7MQ3ib?d8fgP?9}< z+=Fq~wRAxGASYj3PV?df#5vpS8Z=VMGyh<^+%^@dIwMgDIRM5Hr03fF14DJBDLx;W zYL3GHbFFbyO3I#~8;CA5&OvNcT#KELX*bI0gwjUDv(Y7eW4iW*uKuJ7cU7}b45aPO-=Ye4- zIfl-W_tg4W(S5*3r4>B+x%eKXrA-3q%{I|-FvunTAa97M-!IaCy|f$AW8NiWh?pl{ zL-(Kh^73;WB1(nqre9cpO|RRZaH&3a=ltr53Smqeusy)=(bdh3EpeZt$G@zja+qr# zNNliM`mtM?jA@Cz5jh#74%K1vMtgK@MwV2)IzG{$9EP(*sJ7)!;H20%IXB=%fZ++w zNXgUaEq5w!u8_8g*Dndpw`$^@FaH1_3Sk$A|5r&K~*!j)DM#*~m`FC>-j1yT?f zEpg&DQS*buRStyLcoH)}=bi(906=XbXW=sVbS@#JxrF9%u#4~5^rd4jZMbqlVfpnU z^kbs~?Tin9?G%Kp^FeGt%nXP+L;>J&{<`$(E{*_%VmZN zk8%E6`=z~1@`SFq(YxGcY9YqZf63E2zVD5dBMZ-#Pegrx^H}G5(K!pR1AMxXyMQhK zx2p#E?;y?{n5XoQpVT!l*n_+paGQWAu3DF!l0fCwpuJ@hx=8?%(R4T^{4vaXPLw* zCAxBh)0ox~fpa-dUC@&bKiE;1W;?l0q5a8vL37dOpx!Hdd2jw7_TD_6$~Al+wRgL@ zR5Vf2AR0-EWK5bvr4m9#hK!kISay*#Dhin*B}p=uv7HE+GG!TB$t*)M&*yrUDbe@$ z{hV|DI-lc@_TILvwchu6pXa`>`x>RMnM3HjCicUm9BlBbO9ZNvZEaieJB zEPvCvk$qkrroKKqO)m5aA`x@*-IfyXr+jmT76}xn3O#p--b}$%LH^a5e;vkr>@V#Y z-ay68F6ihW(dY+kBEQLe#A*MM_G)FqUJohbizU*9b4_1+0(ro*gg8%&F`4-zuJ5^& zV{a@P_YUQ2kGNt8VLcaMFzpj=CoJGms}*At?$|Xo*FBml8QskNsnBJ;?8y*i-CeWa zYQ;;8Xet=A;SUky!l&#y;G8~{uj#joDocF$WyJc8vzT=FFCJUGpWmbQvN!0!ed%%u z=O+JE-oHJ(ERj&EfREa!W{L?6VN?P-5rBy>#$n}ndOx+`?e3Q%Ga8g>c&13UXHZbk z;|j=Lh%F#M^yzb1+k*A2gF-`jcM?*$iYfJcd8I)bQY!C8T2!?nNH6r02nBs}`+={Eb`P3kFVWP;$Q%(I7zXDg$PcjXFf0+T0qMV42#@6$a~N~)#z`vxDYo(fOBB# ziL}`{&7gMuyn++x769(ciy0^|mAJf;uDUD)SaD*Oi2H;qgeo5r9M245He3YbbucNh z$&+{!QoVj^VbtO01+s;@6M9|hbyJhTl^U#u&7#K!2>Pu z41?}=aG*1B5LWVDq1Q=Ln9wSY0TKljoPrM|g(rh>RM(O*PCK2W3hRE%NLzHf@YmQ+ z97QZ;!3nwZad~b7j!m83gS~?X;%Qdt8RpHri=;qoEmf~A!gOXZS*0%-OD_>8&AMIz zN~=Iir`ra_lH%=qwai?M{;u>qqXnOenyY&Yz2>u80Db)6%5Jyj>`BF(zlV4Kku&tw zL0_(LI!Py;NL%q}KN5OuiqgDZojGs-AB-dl9W*-=PUJ-E*wSSNx)L!yLiW=^5}Hac z^g*^Q$M_A^gGaEJ?_1OX>*9f70~ho!Fh|ta2|2mw_PnwbQ0M_$b#1s}l#V;ZBae1nVd5eV z;va-wQ6*ED;eN%u9ccZt!&aIDlf5wzW<2}fMn!>NuYNIVRT~j0qjtiI2P$=DRG2Zd+74mqQY*X zhI1*ZA}#1KD3kMGHLJP^E!NSU91=o#2f2~S^YW#h5=#>8q+S_?c4wavFKB@TKZItXZ;(rFo;GAj*`qy;oawg_?B3f$n2M(WyDQ zQH#r{kLI7NhIMEE z$fURe{yUC3^OOyoGTw2`c22Lcpz;nPm1qEPxX)>sINNW@{Y;YS5&bfAW?o!FeR37v=a&Yep9 z9f+Ow_o*(7YB%-Xz9|f=51BOzYa|aQ(%xGHQTmpMy?i+IZicWc@#Xmlm%<~Dt$l$9 zQC87^4c!n#SLs&E-(Tk#pG$O1fB*A*xols>qJB!;x1a6VV&OGjYd+kbxtpOYd`Odz z(*Z@5hT!<$A^oP7>$d2~S6Q!MK>a@ZkCbf<)DwdRuV~nHzEZduhymr9U|28RH{>6` zeTwH77A)mq$PyQ`(J%{HBz8T@ed&O6!5T_WL#mHxYe+$&uI2@Nk{=qp4D+W0n4({n zEJLLj>ig=*W#cc4F7dgwmEByd6T6JSkLYvAmmFKh(kAP>;%hRihg{xp{Xkz$&PP!(> zhFQZESopQ^MZp9!VEO_}m-nBv;lA}U#r#({fuRFP;9%|eSq83KxWni*9KA?ct8spj zFWVIy7bUWF$gD}CgjDX%CTnh)`Lbm$0KJ$87NBfnn-Q9?P(hzi1` zACS)-HlYsG8ngEurSbZsPN9bCU0+lUZ9W1N&=dOfR^ zMV25BUC9ZcY-NvV2-T2nda-qksLCU@`~#QpJJLb0;-ngE zv0B7OfW-_=8>^fgwHjl9N1Y{smgB%9>XALESEo~FvkaF@WBb;-i{tmb7?BI)g#Mq9 z+n4`8ayuzE%nsclh~Ea(*x-;dT5k%`}P4gS~AOu*r)c8Ft0jNjYN{<9xk4cXU}Hr11&=0 z5^&#DY_}yq8#rRB4M&B7IXS_%MK0!;nKtp;zt3v#U=dfeR|W2zPfSl zse^pqU$?b5k}7F!ZM`2HLykz6vk#x}uPG5v#Dss92p+=xreLH@`Ro_e-;n*O;qW4_ zx@g({2DOrDt4la;u75ryk|P`(uT{&)L~5j6`?hlAO#h%u+y#e_23Q%^etAY>UO1Q> zvHG?X6ub}C9|ZMIg#o7xlAz$Mr)ss?gI~8E&o(}aH=f!`VkXNua2i*rq;Qd=sf(tT z*33HSl&>1E+tNe4@+;Jt({63?E1P?-CSb8mmSByY@s3r&|9uktBtj&HTD>PyOrNUn zHqk9{%2sElKI|vnB;YE9L5*`bGm�(*{}IqOip7 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≤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 Date: Tue, 26 Jan 2021 23:14:28 +0000 Subject: [PATCH 494/710] shopfloor 13.0.2.1.2 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index d9bb05170ea..dbfb8f2e1cd 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.2.1.1", + "version": "13.0.2.1.2", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From f788ce57f2fa7783ffeb3956191419cf6c0a186b Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 18 Dec 2020 12:34:40 +0100 Subject: [PATCH 495/710] shopfloor: refactor zone picking global vars handling Stop passing around core variables for this scenario. Zone location, picking type and lines order are now passed via headers. This way they are common to the whole scenario session and they can't get out of sync anymore. --- shopfloor/services/zone_picking.py | 596 ++++++------------ .../test_location_content_transfer_mix.py | 15 +- shopfloor/tests/test_zone_picking_base.py | 7 +- .../test_zone_picking_change_pack_lot.py | 44 -- .../tests/test_zone_picking_select_line.py | 357 ++--------- .../test_zone_picking_select_picking_type.py | 26 - .../test_zone_picking_set_line_destination.py | 85 +-- .../tests/test_zone_picking_stock_issue.py | 93 +-- .../tests/test_zone_picking_unload_all.py | 200 ++---- ...est_zone_picking_unload_set_destination.py | 106 +--- .../tests/test_zone_picking_unload_single.py | 84 +-- .../tests/test_zone_picking_zero_check.py | 77 +-- 12 files changed, 381 insertions(+), 1309 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index d90c9e35a4f..263b1565490 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -87,6 +87,68 @@ class ZonePicking(Component): _usage = "zone_picking" _description = __doc__ + @property + def _validation_rules(self): + return super()._validation_rules + ( + # rule to apply, active flag handler + (self.ZONE_LOCATION_ID_HEADER_RULE, self._requires_header_zone_picking), + (self.PICKING_TYPE_ID_HEADER_RULE, self._requires_header_zone_picking), + (self.LINES_ORDER_HEADER_RULE, self._requires_header_zone_picking), + ) + + def _requires_header_zone_picking(self, request, method): + # TODO: maybe we should have a decorator? + return method not in ("select_zone", "scan_location") + + ZONE_LOCATION_ID_HEADER_RULE = ( + # header name, coerce func, ctx handler, mandatory + "HTTP_SERVICE_CTX_ZONE_LOCATION_ID", + int, + "_work_ctx_get_zone_location_id", + True, + ) + PICKING_TYPE_ID_HEADER_RULE = ( + # header name, coerce func, ctx handler, mandatory + "HTTP_SERVICE_CTX_PICKING_TYPE_ID", + int, + "_work_ctx_get_picking_type_id", + True, + ) + LINES_ORDER_HEADER_RULE = ( + # header name, coerce func, ctx handler, mandatory + "HTTP_SERVICE_CTX_LINES_ORDER", + str, + "_work_ctx_get_lines_order", + True, + ) + + def _work_ctx_get_zone_location_id(self, rec_id): + return ( + "current_zone_location", + self.env["stock.location"].browse(rec_id).exists(), + ) + + def _work_ctx_get_picking_type_id(self, rec_id): + return ( + "current_picking_type", + self.env["stock.picking.type"].browse(rec_id).exists(), + ) + + def _work_ctx_get_lines_order(self, order): + return "current_lines_order", order + + @property + def zone_location(self): + return self.work.current_zone_location + + @property + def picking_type(self): + return getattr(self.work, "current_picking_type", None) + + @property + def lines_order(self): + return getattr(self.work, "current_lines_order", "priority") + def _response_for_start(self, message=None): zones = self.work.menu.picking_type_ids.mapped( "default_location_src_id.child_ids" @@ -106,88 +168,65 @@ def _response_for_select_picking_type( message=message, ) - def _response_for_select_line( - self, zone_location, picking_type, move_lines, message=None, popup=None - ): + def _response_for_select_line(self, 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), + data=self._data_for_move_lines(move_lines), message=message, popup=popup, ) def _response_for_set_line_destination( - self, - zone_location, - picking_type, - move_line, - message=None, - confirmation_required=False, + self, 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 = self._data_for_move_line(move_line) data["confirmation_required"] = confirmation_required return self._response( next_state="set_line_destination", data=data, message=message ) - def _response_for_zero_check( - self, zone_location, picking_type, location, message=None - ): + def _response_for_zero_check(self, location, message=None): return self._response( next_state="zero_check", - data=self._data_for_location(zone_location, picking_type, location), + data=self._data_for_location(location), message=message, ) - def _response_for_change_pack_lot( - self, zone_location, picking_type, 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_for_move_line(zone_location, picking_type, move_line), + data=self._data_for_move_line(move_line), message=message, ) def _response_for_unload_all( - self, - zone_location, - picking_type, - move_lines, - message=None, - confirmation_required=False, + self, move_lines, message=None, confirmation_required=False, ): 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 = self._data_for_move_lines(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 - ): - buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) + def _response_for_unload_single(self, move_line, message=None, popup=None): + buffer_lines = self._find_buffer_move_lines() 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), + data=self._data_for_move_line(move_line), message=message, popup=popup or completion_info_popup, ) def _response_for_unload_set_destination( - self, - zone_location, - picking_type, - move_line, - message=None, - confirmation_required=False, + self, 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 = self._data_for_move_line(move_line) data["confirmation_required"] = confirmation_required return self._response( next_state="unload_set_destination", data=data, message=message @@ -214,14 +253,18 @@ def _picking_type_zone_lines(self, zone_location, picking_type): zone_location, picking_type=picking_type ) - def _data_for_move_line(self, zone_location, picking_type, move_line): + def _data_for_move_line(self, move_line, zone_location=None, picking_type=None): + zone_location = zone_location or self.zone_location + picking_type = picking_type or self.picking_type return { "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), "move_line": self.data.move_line(move_line, with_picking=True), } - def _data_for_move_lines(self, zone_location, picking_type, move_lines): + def _data_for_move_lines(self, move_lines, zone_location=None, picking_type=None): + zone_location = zone_location or self.zone_location + picking_type = picking_type or self.picking_type data = { "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), @@ -239,7 +282,9 @@ def _data_for_move_lines(self, zone_location, picking_type, move_lines): ] = move_line.location_id.planned_qty_in_location_is_empty(move_line) return data - def _data_for_location(self, zone_location, picking_type, location): + def _data_for_location(self, location, zone_location=None, picking_type=None): + zone_location = zone_location or self.zone_location + picking_type = picking_type or self.picking_type return { "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), @@ -283,30 +328,27 @@ def _data_for_select_zone(self, zones): def _find_location_move_lines( self, - locations, + locations=None, picking_type=None, package=None, product=None, lot=None, - order="priority", match_user=False, ): """Find lines that potentially need work in given locations.""" return self.search_move_line.search_move_lines_by_location( - locations, - picking_type=picking_type, + locations or self.zone_location, + picking_type=picking_type or self.picking_type, + order=self.lines_order, package=package, product=product, lot=lot, - order=order, match_user=match_user, ) - def _find_buffer_move_lines_domain( - self, zone_location, picking_type, dest_package=None - ): + def _find_buffer_move_lines_domain(self, dest_package=None): domain = [ - ("location_id", "child_of", zone_location.id), + ("location_id", "child_of", self.zone_location.id), ("qty_done", ">", 0), ("state", "not in", ("cancel", "done")), ("result_package_id", "!=", False), @@ -316,14 +358,16 @@ def _find_buffer_move_lines_domain( domain.append(("result_package_id", "=", dest_package.id)) return domain - def _find_buffer_move_lines(self, zone_location, picking_type, dest_package=None): + def _find_buffer_move_lines(self, 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, dest_package + domain = self._find_buffer_move_lines_domain(dest_package) + return ( + self.env["stock.move.line"] + .search(domain) + .sorted(self.search_move_line._sort_key_move_lines(self.lines_order)) ) - return self.env["stock.move.line"].search(domain) def _group_buffer_move_lines_by_package(self, move_lines): data = {} @@ -370,26 +414,19 @@ def scan_location(self, barcode): 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"): + def list_move_lines(self): """List all move lines to pick, sorted Transitions: * select_line: show the list of move lines """ - 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, order=order - ) - return self._response_for_select_line(zone_location, picking_type, move_lines) + return self._list_move_lines(self.zone_location) - def _scan_source_location( - self, zone_location, picking_type, barcode, order="priority" - ): + def _list_move_lines(self, location): + move_lines = self._find_location_move_lines(location) + return self._response_for_select_line(move_lines) + + def _scan_source_location(self, barcode): """Search a location and find available lines into it. """ response = None @@ -399,7 +436,7 @@ def _scan_source_location( if not location: return response, message - if not location.is_sublocation_of(zone_location): + if not location.is_sublocation_of(self.zone_location): response = self._response_for_start( message=self.msg_store.location_not_allowed() ) @@ -407,26 +444,18 @@ def _scan_source_location( product, lot, package = self._find_product_in_location(location) if len(product) > 1 or len(lot) > 1 or len(package) > 1: - response = self.list_move_lines(location.id, picking_type.id, order=order) + response = self._list_move_lines(location) message = self.msg_store.several_products_in_location(location) return response, message move_lines = self._find_location_move_lines( - location, - picking_type=picking_type, - product=product, - lot=lot, - package=package, - order=order, - match_user=True, + location, product=product, lot=lot, package=package, match_user=True, ) if move_lines: - response = self._response_for_set_line_destination( - zone_location, picking_type, first(move_lines) - ) + response = self._response_for_set_line_destination(first(move_lines)) else: # if no move line, narrow the list of move lines on the scanned location - response = self.list_move_lines(location.id, picking_type.id, order=order) + response = self._list_move_lines(location) message = self.msg_store.location_empty(location) return response, message @@ -439,9 +468,7 @@ def _find_product_in_location(self, location): package = quants.package_id return product, lot, package - def _scan_source_package( - self, zone_location, picking_type, barcode, order="priority" - ): + def _scan_source_package(self, barcode): """Search a package and find available lines for it. """ message = None @@ -450,23 +477,15 @@ def _scan_source_package( package = search.package_from_scan(barcode) if not package: return response, message - move_lines = self._find_location_move_lines( - zone_location, picking_type=picking_type, package=package, order=order - ) + move_lines = self._find_location_move_lines(package=package) if move_lines: - response = self._response_for_set_line_destination( - zone_location, picking_type, first(move_lines) - ) + response = self._response_for_set_line_destination(first(move_lines)) else: - response = self.list_move_lines( - zone_location.id, picking_type.id, order=order - ) + response = self._list_move_lines(self.zone_location) message = self.msg_store.package_not_found() return response, message - def _scan_source_product( - self, zone_location, picking_type, barcode, order="priority" - ): + def _scan_source_product(self, barcode): """Search a product and find available lines for it. """ message = None @@ -475,21 +494,15 @@ def _scan_source_product( product = search.product_from_scan(barcode) if not product: return response, message - move_lines = self._find_location_move_lines( - zone_location, picking_type=picking_type, product=product, order=order - ) + move_lines = self._find_location_move_lines(product=product) if move_lines: - response = self._response_for_set_line_destination( - zone_location, picking_type, first(move_lines) - ) + response = self._response_for_set_line_destination(first(move_lines)) else: - response = self.list_move_lines( - zone_location.id, picking_type.id, order=order - ) + response = self._list_move_lines(self.zone_location) message = self.msg_store.product_not_found() return response, message - def _scan_source_lot(self, zone_location, picking_type, barcode, order="priority"): + def _scan_source_lot(self, barcode): """Search a lot and find available lines for it. """ message = None @@ -498,21 +511,15 @@ def _scan_source_lot(self, zone_location, picking_type, barcode, order="priority lot = search.lot_from_scan(barcode) if not lot: return response, message - move_lines = self._find_location_move_lines( - zone_location, picking_type=picking_type, lot=lot, order=order - ) + move_lines = self._find_location_move_lines(lot=lot) if move_lines: - response = self._response_for_set_line_destination( - zone_location, picking_type, first(move_lines) - ) + response = self._response_for_set_line_destination(first(move_lines)) else: - response = self.list_move_lines( - zone_location.id, picking_type.id, order=order - ) + response = self._list_move_lines(self.zone_location) message = self.msg_store.lot_not_found() return response, message - def scan_source(self, zone_location_id, picking_type_id, barcode, order="priority"): + def scan_source(self, barcode): """Select a move line or narrow the list of move lines When the barcode is a location and we can unambiguously know which move @@ -532,12 +539,6 @@ 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 """ - 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) handlers = ( # search by location 1st @@ -550,19 +551,15 @@ def scan_source(self, zone_location_id, picking_type_id, barcode, order="priorit self._scan_source_lot, ) for handler in handlers: - response, message = handler( - zone_location, picking_type, barcode, order=order - ) + response, message = handler(barcode) if response: return self._response(base_response=response, message=message) - response = self.list_move_lines(zone_location.id, picking_type.id, order=order) + response = self.list_move_lines() return self._response( base_response=response, message=self.msg_store.barcode_not_found() ) - def _set_destination_location( - self, zone_location, picking_type, move_line, quantity, confirmation, location - ): + def _set_destination_location(self, move_line, quantity, confirmation, location): location_changed = False response = None @@ -572,15 +569,12 @@ def _set_destination_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( - picking_type.default_location_dest_id + self.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.dest_location_not_allowed(), + move_line, message=self.msg_store.dest_location_not_allowed(), ) return (location_changed, response) @@ -589,8 +583,6 @@ def _set_destination_location( and not confirmation ): 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 @@ -602,10 +594,7 @@ def _set_destination_location( # If no destination package if not move_line.result_package_id: response = self._response_for_set_line_destination( - zone_location, - picking_type, - move_line, - message=self.msg_store.dest_package_required(), + move_line, message=self.msg_store.dest_package_required(), ) return (location_changed, response) # destination location set to the scanned one @@ -621,11 +610,9 @@ def _set_destination_location( stock.validate_moves(move_line.move_id) location_changed = True # Zero check - zero_check = picking_type.shopfloor_zero_check + zero_check = self.picking_type.shopfloor_zero_check if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): - response = self._response_for_zero_check( - zone_location, picking_type, move_line.location_id - ) + response = self._response_for_zero_check(move_line.location_id) return (location_changed, response) def _is_package_empty(self, package): @@ -653,9 +640,7 @@ def _move_line_full_qty(self, move_line, qty): move_line.product_uom_qty - qty, precision_rounding=rounding ) - def _set_destination_package( - self, zone_location, picking_type, move_line, quantity, package - ): + def _set_destination_package(self, move_line, quantity, package): package_changed = False response = None # A valid package is: @@ -663,18 +648,12 @@ def _set_destination_package( # * not used as destination for another move line if not self._is_package_empty(package): response = self._response_for_set_line_destination( - zone_location, - picking_type, - move_line, - message=self.msg_store.package_not_empty(package), + move_line, message=self.msg_store.package_not_empty(package), ) return (package_changed, response) if self._is_package_already_used(package): response = self._response_for_set_line_destination( - zone_location, - picking_type, - move_line, - message=self.msg_store.package_already_used(package), + move_line, message=self.msg_store.package_already_used(package), ) return (package_changed, response) # the quantity done is set to the passed quantity @@ -684,8 +663,6 @@ def _set_destination_package( qty_greater = compare == 1 if qty_greater: 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), ) @@ -707,22 +684,13 @@ def _set_destination_package( move_line.shopfloor_user_id = self.env.user package_changed = True # Zero check - zero_check = picking_type.shopfloor_zero_check + zero_check = self.picking_type.shopfloor_zero_check if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): - response = self._response_for_zero_check( - zone_location, picking_type, move_line.location_id - ) + response = self._response_for_zero_check(move_line.location_id) return (package_changed, response) def set_destination( - self, - zone_location_id, - picking_type_id, - move_line_id, - barcode, - quantity, - confirmation=False, - order="priority", + self, move_line_id, barcode, quantity, confirmation=False, ): """Set a destination location (and done) or a destination package (in buffer) @@ -767,12 +735,6 @@ def set_destination( * set_line_destination+confirmation_required: the scanned location is not in the expected one but is valid (in picking type's default destination) """ - 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()) @@ -786,12 +748,7 @@ def set_destination( location = search.location_from_scan(barcode) if location: pkg_moved, response = self._set_destination_location( - zone_location, - picking_type, - move_line, - quantity, - confirmation, - location, + move_line, quantity, confirmation, location, ) if response: return response @@ -801,7 +758,7 @@ def set_destination( if package: location = move_line.location_dest_id pkg_moved, response = self._set_destination_package( - zone_location, picking_type, move_line, quantity, package + move_line, quantity, package ) if response: return response @@ -814,18 +771,16 @@ def set_destination( 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 - ) + return self._response_for_set_line_destination(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) + response = self.list_move_lines() return self._response(base_response=response, message=message) - def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero): + def is_zero(self, 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 @@ -836,12 +791,6 @@ 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 """ - 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()) @@ -854,10 +803,9 @@ def is_zero(self, zone_location_id, picking_type_id, move_line_id, zero): # => 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, + ref=self.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) + return self.list_move_lines() def _domain_stock_issue_unlink_lines(self, move_line): # Since we have not enough stock, delete the move lines, which will @@ -879,7 +827,7 @@ def _domain_stock_issue_unlink_lines(self, move_line): ] return domain - def stock_issue(self, zone_location_id, picking_type_id, move_line_id): + 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 @@ -907,12 +855,6 @@ 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 """ - 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()) @@ -946,13 +888,10 @@ def stock_issue(self, zone_location_id, picking_type_id, move_line_id): 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) + return self._response_for_set_line_destination(move.move_line_ids[0]) + return self.list_move_lines() - def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barcode): + def change_pack_lot(self, 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 @@ -968,12 +907,6 @@ 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 """ - 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()) @@ -981,12 +914,8 @@ def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barco # 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 - ) - response_error_func = functools.partial( - self._response_for_change_pack_lot, zone_location, picking_type - ) + response_ok_func = functools.partial(self._response_for_set_line_destination) + response_error_func = functools.partial(self._response_for_change_pack_lot) response = None change_package_lot = self.actions_for("change.package.lot") # handle lot @@ -1011,13 +940,10 @@ def change_pack_lot(self, zone_location_id, picking_type_id, move_line_id, barco 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), + move_line, message=self.msg_store.no_package_or_lot_for_barcode(barcode), ) - def prepare_unload(self, zone_location_id, picking_type_id): + def prepare_unload(self): """Initiate the unloading of the buffer The buffer is composed of move lines: @@ -1044,46 +970,25 @@ def prepare_unload(self, zone_location_id, picking_type_id): destination location * select_line: no remaining move lines in buffer """ - 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) + move_lines = self._find_buffer_move_lines() 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 - ) + return self._response_for_unload_set_destination(move_lines) elif len(move_lines) > 1 and len(location_dest) == 1: - return self._response_for_unload_all( - zone_location, picking_type, move_lines - ) + return self._response_for_unload_all(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) + return self._response_for_unload_single(first(move_lines)) + return self.list_move_lines() - def _set_destination_all_response( - self, zone_location, picking_type, buffer_lines, message=None - ): + def _set_destination_all_response(self, 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) + return self._response_for_unload_all(buffer_lines, message=message) + move_lines = self._find_location_move_lines() if move_lines: - return self._response_for_select_line( - zone_location, picking_type, move_lines, message=message - ) + return self._response_for_select_line(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 - ): + def set_destination_all(self, barcode, confirmation=False): """Set the destination for all the lines in the buffer Look in ``prepare_unload`` for the definition of the buffer. @@ -1104,16 +1009,10 @@ def set_destination_all( expected one but is valid (in picking type's default destination) * select_line: no remaining move lines in buffer """ - 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) + buffer_lines = self._find_buffer_move_lines() if location: error = None location_dest = buffer_lines.mapped("location_dest_id") @@ -1121,12 +1020,12 @@ def set_destination_all( 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): + if not location.is_sublocation_of( + self.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 - ) + return self._set_destination_all_response(buffer_lines, message=error) # 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 @@ -1135,8 +1034,6 @@ def set_destination_all( 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( first(buffer_lines.location_dest_id), location @@ -1150,19 +1047,17 @@ def set_destination_all( 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) + buffer_lines = self._find_buffer_move_lines() else: message = self.msg_store.no_location_found() - return self._set_destination_all_response( - zone_location, picking_type, buffer_lines, message=message - ) + return self._set_destination_all_response(buffer_lines, message=message) 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): + def unload_split(self): """Indicates that now the buffer must be treated line per line Called from a button, users decides to handle destinations one by one. @@ -1175,60 +1070,41 @@ 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 """ - 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) + buffer_lines = self._find_buffer_move_lines() # 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) - ) + return self._response_for_unload_single(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) - ) + return self._response_for_unload_set_destination(first(buffer_lines)) # no remaining move lines in buffer - move_lines = self._find_location_move_lines(zone_location, picking_type) + move_lines = self._find_location_move_lines() return self._response_for_select_line( - zone_location, - picking_type, - move_lines, - message=self.msg_store.buffer_complete(), + move_lines, message=self.msg_store.buffer_complete(), ) - def _unload_response(self, zone_location, picking_type, unload_single_message=None): + def _unload_response(self, 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) + move_lines = self._find_buffer_move_lines() if move_lines: return self._response_for_unload_single( - zone_location, - picking_type, - first(move_lines), - message=unload_single_message, + 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) + move_lines = self._find_location_move_lines() if move_lines: return self._response_for_select_line( - zone_location, - picking_type, - move_lines, - message=self.msg_store.buffer_complete(), + 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) + message=self.msg_store.picking_type_complete(self.picking_type) ) - def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcode): + def unload_scan_pack(self, package_id, barcode): """Scan the destination package to check user moves the good one The "unload_single" screen proposes a package (which has been @@ -1241,33 +1117,19 @@ def unload_scan_pack(self, zone_location_id, picking_type_id, package_id, barcod * select_line: no remaining move lines in buffer * start: no remaining move lines in picking type """ - 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 - ) + move_lines = self._find_buffer_move_lines(dest_package=scanned_package) if move_lines: - return self._response_for_unload_set_destination( - zone_location, picking_type, first(move_lines) - ) + return self._response_for_unload_set_destination(first(move_lines)) return self._unload_response( - zone_location, - picking_type, unload_single_message=self.msg_store.barcode_no_match(package.name), ) @@ -1276,9 +1138,7 @@ def _lock_lines(self, 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 - ): + def unload_set_destination(self, package_id, barcode, confirmation=False): """Scan the final destination for move lines in the buffer with the destination package @@ -1298,35 +1158,22 @@ def unload_set_destination( * select_line: no remaining move lines in buffer * start: no remaining move lines to process in the picking type """ - 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) - buffer_lines = self._find_buffer_move_lines( - zone_location, picking_type, dest_package=package - ) + buffer_lines = self._find_buffer_move_lines(dest_package=package) if not package.exists() or not buffer_lines: - move_lines = self._find_location_move_lines(zone_location, picking_type) + move_lines = self._find_location_move_lines() return self._response_for_select_line( - zone_location, - picking_type, - move_lines, - message=self.msg_store.record_not_found(), + move_lines, message=self.msg_store.record_not_found(), ) 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 + self.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, first(buffer_lines), message=self.msg_store.dest_location_not_allowed(), ) @@ -1338,8 +1185,6 @@ def unload_set_destination( 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( first(buffer_lines.location_dest_id), location @@ -1357,32 +1202,24 @@ def unload_set_destination( stock = self.actions_for("stock") stock.validate_moves(moves) - buffer_lines = self._find_buffer_move_lines(zone_location, picking_type) + buffer_lines = self._find_buffer_move_lines() 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) - ) - move_lines = self._find_location_move_lines(zone_location, picking_type) + return self._response_for_unload_single(first(buffer_lines)) + move_lines = self._find_location_move_lines() if move_lines: return self._response_for_select_line( - zone_location, - picking_type, - move_lines, - message=self.msg_store.buffer_complete(), + move_lines, message=self.msg_store.buffer_complete(), ) return self._response_for_start( - message=self.msg_store.picking_type_complete(picking_type) + message=self.msg_store.picking_type_complete(self.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, - first(buffer_lines), - message=self.msg_store.no_location_found(), + first(buffer_lines), message=self.msg_store.no_location_found(), ) @@ -1401,98 +1238,59 @@ def scan_location(self): 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"}, "move_line_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"}, - } + return {} 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"}, - } + return {} 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"}, "barcode": {"required": False, "nullable": True, "type": "string"}, } 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}, diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py index b7f6753cb5f..acc4704c49b 100644 --- a/shopfloor/tests/test_location_content_transfer_mix.py +++ b/shopfloor/tests/test_location_content_transfer_mix.py @@ -96,6 +96,8 @@ 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 + self.zp_service.work.current_zone_location = zone_location + self.zp_service.work.current_picking_type = picking_type move_lines = picking.move_line_ids.filtered( lambda m: m.state not in ("cancel", "done") ) @@ -107,11 +109,7 @@ def _zone_picking_process_line(self, move_line, dest_location=None): 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", - ) + response = self.zp_service.list_move_lines() available_move_line_ids = [ r["id"] for r in response["data"]["select_line"]["move_lines"] ] @@ -122,12 +120,7 @@ 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, - confirmation=True, + move_line.id, dest_location.barcode, qty, confirmation=True, ) assert response["message"]["message_type"] == "success" self.assertEqual(move_line.state, "done") diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 317f06730e9..a8e8e6f10ea 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -249,7 +249,12 @@ def setUpClassBaseData(cls, *args, **kwargs): def setUp(self): super().setUp() - with self.work_on_services(menu=self.menu, profile=self.profile) as work: + with self.work_on_services( + menu=self.menu, + profile=self.profile, + current_zone_location=self.zone_location, + current_picking_type=self.picking_type, + ) as work: self.service = work.component(usage="zone_picking") def _assert_response_select_zone(self, response, zone_locations, message=None): diff --git a/shopfloor/tests/test_zone_picking_change_pack_lot.py b/shopfloor/tests/test_zone_picking_change_pack_lot.py index 51985e041a7..107829fd448 100644 --- a/shopfloor/tests/test_zone_picking_change_pack_lot.py +++ b/shopfloor/tests/test_zone_picking_change_pack_lot.py @@ -12,50 +12,6 @@ class ZonePickingChangePackLotCase(ZonePickingCommonCase): error, the "change.package.lot" component is tested in its own tests. """ - 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 diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index afb51be0934..e7555b61e4a 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -14,9 +14,11 @@ class ZonePickingSelectLineCase(ZonePickingCommonCase): """ + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + 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 @@ -32,27 +34,24 @@ def test_list_move_lines_order(self): 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" - ) + self.service.work.current_lines_order = "location" + move_lines = self.service._find_location_move_lines() 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" - ) + move_lines = self.service._find_location_move_lines() 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" - ) + + self.service.work.current_lines_order = "priority" + move_lines = self.service._find_location_move_lines() 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 @@ -63,113 +62,47 @@ def test_list_move_lines_order(self): 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" - ) + move_lines = self.service._find_location_move_lines() 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 - 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.service.work.current_lines_order = "location" + response = self.service.dispatch("list_move_lines", params={}) + move_lines = self.service._find_location_move_lines() + res = [ + x["location_src"]["name"] + for x in response["data"]["select_line"]["move_lines"] + ] + self.assertEqual(res, [x.location_id.name for x in move_lines]) + self.maxDiff = None self.assert_response_select_line( - response, - zone_location, - picking_type, - move_lines, + response, self.zone_location, self.picking1.picking_type_id, 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" - ) + response = self.service.dispatch("list_move_lines", params={}) + move_lines = self.service._find_location_move_lines() 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(), + response, self.zone_location, self.picking_type, move_lines, ) 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, - }, + "scan_source", 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_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, - }, + "scan_source", params={"barcode": self.zone_sublocation1.barcode}, ) move_line = self.picking1.move_line_ids self.assert_response_set_line_destination( @@ -192,15 +125,8 @@ def test_scan_source_barcode_location_two_move_lines_same_product(self): 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, - }, + "scan_source", params={"barcode": self.zone_sublocation1.barcode}, ) move_line = self.picking1.move_line_ids self.assert_response_set_line_destination( @@ -213,12 +139,7 @@ def test_scan_source_barcode_location_two_move_lines_same_product(self): 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, - }, + "scan_source", params={"barcode": self.zone_sublocation1.barcode}, ) move_line = new_picking.move_line_ids self.assert_response_set_line_destination( @@ -233,20 +154,13 @@ def test_scan_source_barcode_location_several_move_lines(self): 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, - }, + "scan_source", params={"barcode": self.zone_sublocation2.barcode}, ) move_lines = self.picking2.move_line_ids self.assert_response_select_line( response, - zone_location=self.zone_sublocation2, + zone_location=self.zone_location, picking_type=self.picking_type, move_lines=move_lines, message=self.service.msg_store.several_products_in_location( @@ -258,22 +172,11 @@ 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, + "scan_source", params={"barcode": package.name}, ) + move_lines = self.service._find_location_move_lines(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( @@ -287,17 +190,10 @@ 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, - }, + "scan_source", params={"barcode": self.free_package.name}, ) - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( response, @@ -311,21 +207,10 @@ 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, + "scan_source", params={"barcode": self.product_a.barcode}, ) + move_line = self.service._find_location_move_lines(product=self.product_a,) self.assert_response_set_line_destination( response, zone_location=self.zone_location, @@ -337,17 +222,10 @@ 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, - }, + "scan_source", params={"barcode": self.free_product.barcode}, ) - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( response, @@ -361,22 +239,9 @@ 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, - ) + response = self.service.dispatch("scan_source", params={"barcode": lot.name},) + move_lines = self.service._find_location_move_lines(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( @@ -390,17 +255,10 @@ 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, - }, + "scan_source", params={"barcode": self.free_lot.name}, ) - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( response, @@ -411,17 +269,15 @@ def test_scan_source_barcode_lot_not_found(self): ) 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, + "zone_location_id": self.zone_location.id, + "picking_type_id": self.picking_type.id, "barcode": "UNKNOWN", }, ) - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( response, zone_location=self.zone_location, @@ -437,122 +293,60 @@ def test_scan_source_multi_users(self): 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, - ) + response = self.service.scan_source(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, + 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 + env=env, + menu=self.menu, + profile=self.profile, + current_zone_location=self.zone_location, + current_picking_type=self.picking_type, ) as work: service = work.component(usage="zone_picking") - response = service.scan_source( - zone_location.id, - picking_type.id, - self.zone_sublocation1.barcode, - ) + response = service.scan_source(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 - 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, - }, - ) + response = self.service.dispatch("prepare_unload", params={},) # check response - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( - response, - zone_location, - picking_type, - move_lines, + response, self.zone_location, self.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, - }, - ) + response = self.service.dispatch("prepare_unload", params={},) # check response self.assert_response_unload_set_destination( - response, - zone_location, - picking_type, - move_line, + response, self.zone_location, self.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( @@ -567,48 +361,29 @@ def test_prepare_unload_buffer_multi_line_same_destination(self): 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, - }, - ) + response = self.service.dispatch("prepare_unload", params={},) # check response self.assert_response_unload_all( response, - zone_location, - picking_type, + self.zone_location, + self.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" + "list_move_lines", params={"order": "location"}, ) + # TODO: order by location? + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( - response, - zone_location, - picking_type, - move_lines, + response, self.zone_location, self.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 7638292fa2f..25657004be0 100644 --- a/shopfloor/tests/test_zone_picking_select_picking_type.py +++ b/shopfloor/tests/test_zone_picking_select_picking_type.py @@ -10,32 +10,6 @@ class ZonePickingSelectPickingTypeCase(ZonePickingCommonCase): """ - 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 diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index a4c1594b92b..3e1ee753b93 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -10,45 +10,15 @@ class ZonePickingSetLineDestinationCase(ZonePickingCommonCase): """ + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + 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, @@ -56,8 +26,7 @@ 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): @@ -72,8 +41,6 @@ def test_set_destination_location_confirm(self): 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, @@ -95,8 +62,6 @@ def test_set_destination_location_confirm(self): 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, @@ -115,8 +80,6 @@ def test_set_destination_location_confirm(self): 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, @@ -124,7 +87,7 @@ def test_set_destination_location_confirm(self): }, ) # Check response - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( response, @@ -145,8 +108,6 @@ def test_set_destination_location_move_invalid_location(self): 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, @@ -186,8 +147,6 @@ def test_set_destination_location_no_other_move_line_full_qty(self): 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, @@ -200,7 +159,7 @@ def test_set_destination_location_no_other_move_line_full_qty(self): 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 = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( response, @@ -237,8 +196,6 @@ def test_set_destination_location_no_other_move_line_partial_qty(self): 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, @@ -284,8 +241,6 @@ def test_set_destination_location_several_move_line_full_qty(self): 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 @@ -309,7 +264,7 @@ def test_set_destination_location_several_move_line_full_qty(self): 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 = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( response, @@ -348,8 +303,6 @@ def test_set_destination_location_several_move_line_partial_qty(self): 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 @@ -380,8 +333,6 @@ def test_set_destination_location_zero_check(self): 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, @@ -391,10 +342,7 @@ 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): @@ -419,8 +367,6 @@ def test_set_destination_package_full_qty(self): 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, @@ -442,7 +388,7 @@ def test_set_destination_package_full_qty(self): ], ) # Check response - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( response, @@ -475,8 +421,6 @@ def test_set_destination_package_partial_qty(self): 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, @@ -513,7 +457,7 @@ def test_set_destination_package_partial_qty(self): ], ) # Check response - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( response, @@ -539,8 +483,6 @@ def test_set_destination_package_zero_check(self): 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, @@ -550,8 +492,5 @@ 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_stock_issue.py b/shopfloor/tests/test_zone_picking_stock_issue.py index d755113e700..bd4b4f110a3 100644 --- a/shopfloor/tests/test_zone_picking_stock_issue.py +++ b/shopfloor/tests/test_zone_picking_stock_issue.py @@ -10,45 +10,16 @@ class ZonePickingStockIssueCase(ZonePickingCommonCase): """ + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + 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, - }, + "stock_issue", params={"move_line_id": 1234567890}, ) 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): @@ -57,21 +28,13 @@ def test_stock_issue_no_more_reservation(self): 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, - }, + "stock_issue", params={"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) + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( - response, - zone_location, - picking_type, - move_lines, + response, zone_location, picking_type, move_lines, ) def test_stock_issue1(self): @@ -82,21 +45,13 @@ def test_stock_issue1(self): 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, - }, + "stock_issue", params={"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) + move_lines = self.service._find_location_move_lines() 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( @@ -127,21 +82,13 @@ def test_stock_issue2(self): # 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, - }, + "stock_issue", params={"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, + response, zone_location, picking_type, move.move_line_ids, ) # Check the inventory inventory = self.env["stock.inventory"].search( @@ -174,21 +121,13 @@ def test_stock_issue3(self): # 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, - }, + "stock_issue", params={"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, + 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 8880b4dcdaf..3f8ae15d8c1 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -11,33 +11,9 @@ class ZonePickingUnloadAllCase(ZonePickingCommonCase): """ - 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 setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id def test_set_destination_all_different_destination(self): zone_location = self.zone_location @@ -51,18 +27,10 @@ def test_set_destination_all_different_destination(self): 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, + 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, + move_line2, move_line2.product_uom_qty, another_package, ) # set destination location for all lines in the buffer response = self.service.dispatch( @@ -74,7 +42,7 @@ def test_set_destination_all_different_destination(self): }, ) # check response - buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + buffer_lines = self.service._find_buffer_move_lines() self.assert_response_unload_all( response, zone_location, @@ -115,18 +83,10 @@ def test_set_destination_all_confirm_destination(self): ) # 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, + 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, + move_line2, move_line2.product_uom_qty, another_package, ) # set an allowed destination location (inside the picking type default # destination location) for all lines in the buffer with a non-expected @@ -134,23 +94,17 @@ def test_set_destination_all_confirm_destination(self): # 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_sublocation2.barcode, - }, + "set_destination_all", params={"barcode": packing_sublocation2.barcode}, ) # check response: this destination needs the user confirmation - buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + buffer_lines = self.service._find_buffer_move_lines() self.assert_response_unload_all( response, zone_location, picking_type, buffer_lines, message=self.service.msg_store.confirm_location_changed( - packing_sublocation1, - packing_sublocation2, + packing_sublocation1, packing_sublocation2, ), confirmation_required=True, ) @@ -159,15 +113,10 @@ def test_set_destination_all_confirm_destination(self): # 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, - }, + "set_destination_all", params={"barcode": packing_sublocation1.barcode}, ) # check response: OK - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( response, zone_location, @@ -186,35 +135,22 @@ def test_set_destination_all_ok(self): ) # 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, + 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, + 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, - }, + "set_destination_all", params={"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) + buffer_lines = self.service._find_buffer_move_lines() self.assertFalse(buffer_lines) # check response - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( response, zone_location, @@ -241,27 +177,14 @@ def test_set_destination_all_partial_qty_done_ok(self): ) # 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, + 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, + move_line_h, move_line_h.product_uom_qty, another_package, # partial qty ) # 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, - }, + "set_destination_all", params={"barcode": self.packing_location.barcode}, ) # check data # picking validated @@ -279,10 +202,10 @@ def test_set_destination_all_partial_qty_done_ok(self): 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) + buffer_lines = self.service._find_buffer_move_lines() self.assertFalse(buffer_lines) # check response - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( response, zone_location, @@ -297,22 +220,13 @@ def test_set_destination_all_location_not_allowed(self): 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, + 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, - }, + "set_destination_all", params={"barcode": self.customer_location.barcode}, ) # check response - buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + buffer_lines = self.service._find_buffer_move_lines() self.assert_response_unload_all( response, zone_location, @@ -327,22 +241,13 @@ def test_set_destination_all_location_not_found(self): 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, + 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", - }, + "set_destination_all", params={"barcode": "UNKNOWN"}, ) # check response - buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + buffer_lines = self.service._find_buffer_move_lines() self.assert_response_unload_all( response, zone_location, @@ -354,15 +259,9 @@ def test_set_destination_all_location_not_found(self): 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, - }, - ) + response = self.service.dispatch("unload_split", params={},) # check response - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( response, zone_location, @@ -377,26 +276,13 @@ def test_unload_split_buffer_one_line(self): 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, - }, + move_line, move_line.product_uom_qty, self.free_package, ) + response = self.service.dispatch("unload_split", params={},) # check response - buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + buffer_lines = self.service._find_buffer_move_lines() 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): @@ -411,21 +297,11 @@ def test_unload_split_buffer_multi_lines(self): 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, + 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, - }, - ) + response = self.service.dispatch("unload_split", params={},) # check response - buffer_lines = self.service._find_buffer_move_lines(zone_location, picking_type) + buffer_lines = self.service._find_buffer_move_lines() completion_info = self.service.actions_for("completion.info") completion_info_popup = completion_info.popup(buffer_lines) self.assert_response_unload_single( diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index f0c475cb1e4..d9ae3ae9637 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -29,47 +29,18 @@ def setUpClassBaseData(cls, *args, **kwargs): cls.picking_z = cls._create_picking(lines=[(cls.product_z, 40)]) cls._update_qty_in_location(cls.zone_sublocation1, cls.product_z, 32) + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + 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(), + params={"package_id": 1234567890, "barcode": "BARCODE"}, ) - 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) + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( response, zone_location, @@ -84,20 +55,11 @@ def test_unload_set_destination_no_location_found(self): 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, + 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", - }, + params={"package_id": self.free_package.id, "barcode": "UNKNOWN"}, ) self.assert_response_unload_set_destination( response, @@ -113,17 +75,11 @@ def test_unload_set_destination_location_not_allowed(self): 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, + 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, }, @@ -143,11 +99,7 @@ def test_unload_set_destination_location_move_not_allowed(self): 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, + move_line, move_line.product_uom_qty, self.free_package, ) response = self.service.dispatch( "unload_set_destination", @@ -194,18 +146,12 @@ def test_unload_set_destination_confirm_location(self): ) # set the destination package self.service._set_destination_package( - zone_location, - picking_type, - move_line, - move_line.product_uom_qty, - self.free_package, + move_line, 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_sublocation2.barcode, }, @@ -238,17 +184,11 @@ def test_unload_set_destination_ok_buffer_empty(self): ) # set the destination package self.service._set_destination_package( - zone_location, - picking_type, - move_line, - move_line.product_uom_qty, - self.free_package, + 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, @@ -258,7 +198,7 @@ def test_unload_set_destination_ok_buffer_empty(self): 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) + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( response, zone_location, @@ -279,11 +219,7 @@ def test_unload_set_destination_ok_buffer_not_empty(self): move_lines, self.free_package | self.another_package ): self.service._set_destination_package( - zone_location, - picking_type, - move_line, - move_line.product_uom_qty, - package_dest, + move_line, move_line.product_uom_qty, package_dest, ) free_package_line = move_lines.filtered( lambda l: l.result_package_id == self.free_package @@ -294,8 +230,6 @@ def test_unload_set_destination_ok_buffer_not_empty(self): 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, }, @@ -311,7 +245,7 @@ def test_unload_set_destination_ok_buffer_not_empty(self): self.assertEqual(self.picking5.move_line_ids, another_package_line) # check response - buffer_line = self.service._find_buffer_move_lines(zone_location, picking_type) + buffer_line = self.service._find_buffer_move_lines() completion_info = self.service.actions_for("completion.info") completion_info_popup = completion_info.popup(buffer_line) self.assert_response_unload_single( @@ -343,17 +277,11 @@ def test_unload_set_destination_partially_available_backorder(self): ) # set the destination package self.service._set_destination_package( - zone_location, - picking_type, - move_line, - move_line.product_uom_qty, - self.free_package, + 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, @@ -372,7 +300,7 @@ def test_unload_set_destination_partially_available_backorder(self): 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) + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( response, zone_location, diff --git a/shopfloor/tests/test_zone_picking_unload_single.py b/shopfloor/tests/test_zone_picking_unload_single.py index 1db582c00ca..e719a8b9bb4 100644 --- a/shopfloor/tests/test_zone_picking_unload_single.py +++ b/shopfloor/tests/test_zone_picking_unload_single.py @@ -10,54 +10,21 @@ class ZonePickingUnloadSingleCase(ZonePickingCommonCase): """ + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + 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, + 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", - }, + "unload_scan_pack", params={"package_id": 1234567890, "barcode": "UNKNOWN"}, ) completion_info = self.service.actions_for("completion.info") completion_info_popup = completion_info.popup(move_line) @@ -75,15 +42,9 @@ def test_unload_scan_pack_wrong_parameters(self): {"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", - }, + "unload_scan_pack", params={"package_id": 1234567890, "barcode": "UNKNOWN"}, ) - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() self.assert_response_select_line( response, zone_location, @@ -95,13 +56,7 @@ def test_unload_scan_pack_wrong_parameters(self): # => 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", - }, + "unload_scan_pack", params={"package_id": 1234567890, "barcode": "UNKNOWN"}, ) self.assert_response_start( response, @@ -114,26 +69,17 @@ def test_unload_scan_pack_barcode_match(self): 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, + 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, + response, zone_location, picking_type, move_line, ) def test_unload_scan_pack_barcode_not_match(self): @@ -145,17 +91,11 @@ def test_unload_scan_pack_barcode_not_match(self): ) # set the destination package self.service._set_destination_package( - zone_location, - picking_type, - move_line, - move_line.product_uom_qty, - self.free_package, + 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, }, diff --git a/shopfloor/tests/test_zone_picking_zero_check.py b/shopfloor/tests/test_zone_picking_zero_check.py index 48ad12ab064..e0661f7f61b 100644 --- a/shopfloor/tests/test_zone_picking_zero_check.py +++ b/shopfloor/tests/test_zone_picking_zero_check.py @@ -10,48 +10,16 @@ class ZonePickingZeroCheckCase(ZonePickingCommonCase): """ + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + 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, - }, + "is_zero", params={"move_line_id": 1234567890, "zero": True}, ) 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): @@ -60,20 +28,11 @@ def test_is_zero_is_empty(self): 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, - }, + "is_zero", params={"move_line_id": move_line.id, "zero": True}, ) - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() 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): @@ -82,20 +41,11 @@ def test_is_zero_is_not_empty(self): 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, - }, + "is_zero", params={"move_line_id": move_line.id, "zero": False}, ) - move_lines = self.service._find_location_move_lines(zone_location, picking_type) + move_lines = self.service._find_location_move_lines() 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( [ @@ -109,7 +59,6 @@ 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 773d27e192dbceef9fd6730d95d81884470ae0a8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 12 Jan 2021 10:47:41 +0100 Subject: [PATCH 496/710] shopfloor: data convert validate recordset model --- shopfloor/actions/data.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index f1d3bbb6440..540148a6435 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -1,10 +1,31 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from functools import wraps + from odoo import fields from odoo.addons.component.core import Component +def ensure_model(model_name): + """Decorator to ensure data method is called w/ the right recordset.""" + + def _ensure_model(func): + @wraps(func) + def wrapped(*args, **kwargs): + # 1st arg is `self` + record = args[1] + if record is not None: + assert ( + record._name == model_name + ), f"Expected model: {model_name}. Got: {record._name}" + return func(*args, **kwargs) + + return wrapped + + return _ensure_model + + class DataAction(Component): """Provide methods to share data structures @@ -25,6 +46,7 @@ def _jsonify(self, recordset, parser, multi=False, **kw): def _simple_record_parser(self): return ["id", "name"] + @ensure_model("res.partner") def partner(self, record, **kw): return self._jsonify(record, self._partner_parser, **kw) @@ -35,6 +57,7 @@ def partners(self, record, **kw): def _partner_parser(self): return self._simple_record_parser() + @ensure_model("stock.location") def location(self, record, **kw): return self._jsonify( record.with_context(location=record.id), self._location_parser, **kw @@ -47,6 +70,7 @@ def locations(self, record, **kw): def _location_parser(self): return ["id", "name", "barcode"] + @ensure_model("stock.picking") def picking(self, record, **kw): return self._jsonify(record, self._picking_parser, **kw) @@ -66,6 +90,7 @@ def _picking_parser(self): "scheduled_date", ] + @ensure_model("stock.quant.package") def package(self, record, picking=None, with_packaging=False, **kw): """Return data for a stock.quant.package @@ -101,6 +126,7 @@ def _package_packaging_parser(self): ("packaging_id:packaging", self._packaging_parser), ] + @ensure_model("product.packaging") def packaging(self, record, **kw): return self._jsonify(record, self._packaging_parser, **kw) @@ -116,6 +142,7 @@ def _packaging_parser(self): "qty", ] + @ensure_model("stock.production.lot") def lot(self, record, **kw): return self._jsonify(record, self._lot_parser, **kw) @@ -126,6 +153,7 @@ def lots(self, record, **kw): def _lot_parser(self): return self._simple_record_parser() + ["ref"] + @ensure_model("stock.move.line") def move_line(self, record, with_picking=False, **kw): record = record.with_context(location=record.location_id.id) parser = self._move_line_parser @@ -166,6 +194,7 @@ def _move_line_parser(self): ), ] + @ensure_model("stock.package_level") def package_level(self, record, **kw): return self._jsonify(record, self._package_level_parser) @@ -200,6 +229,7 @@ def _package_level_parser(self): ), ] + @ensure_model("product.product") def product(self, record, **kw): return self._jsonify(record, self._product_parser, **kw) @@ -232,6 +262,7 @@ def _product_supplier_code(self, rec, field): ) return supplier_info.product_code or "" + @ensure_model("stock.picking.batch") def picking_batch(self, record, with_pickings=False, **kw): parser = self._picking_batch_parser if with_pickings: @@ -245,6 +276,7 @@ def picking_batches(self, record, with_pickings=False, **kw): def _picking_batch_parser(self): return ["id", "name", "picking_count", "move_line_count", "total_weight:weight"] + @ensure_model("stock.picking.type") def picking_type(self, record, **kw): parser = self._picking_type_parser return self._jsonify(record, parser, **kw) From 1798198360eb2d30655cae74e8837b16c33dba63 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 Jan 2021 08:52:24 +0100 Subject: [PATCH 497/710] shopfloor: bump 13.0.2.2.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index dbfb8f2e1cd..c437b916870 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.2.1.2", + "version": "13.0.2.2.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From f6355abee09465fdeeda355e23d7c1008cfaca67 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 28 Jan 2021 08:11:59 +0000 Subject: [PATCH 498/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index f206574a9f3..cf30226a3dc 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -292,6 +292,7 @@ msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view msgid "Group By" msgstr "" @@ -545,7 +546,7 @@ msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/service.py:0 #, python-format -msgid "No operation types configured on menu {} for warehouse {}." +msgid "No operation types configured on menu {}." msgstr "" #. module: shopfloor @@ -624,6 +625,11 @@ msgstr "" msgid "Operation's already running. Would you like to take it over?" msgstr "" +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__options +msgid "Options" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -816,12 +822,17 @@ 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_id -#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_profil +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view msgid "Profile" msgstr "" +#. module: shopfloor +#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_profile +#: 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" @@ -891,6 +902,12 @@ msgstr "" msgid "SF unloaded" msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Same package {} is already assigned." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -906,6 +923,7 @@ msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scenario +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view msgid "Scenario" msgstr "" @@ -1145,6 +1163,12 @@ msgid "" "id=\"%d\">%s has been created." msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The content of {} cannot be transferred with this scenario." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/services/cluster_picking.py:0 #, python-format @@ -1157,6 +1181,12 @@ msgstr "" 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 cannot be transferred with this scenario." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format From 0a7826029a13f0c13e0c096e1a9aad11d4d0520f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 25 Jan 2021 15:20:27 +0100 Subject: [PATCH 499/710] zone picking: fix "zero check" The client js application needs the move line's id to confirm or not the zero quantity using "/is_zero". The backend did not send the move line's data when going to the "zero_check" state. --- shopfloor/services/zone_picking.py | 15 +++--- shopfloor/tests/test_zone_picking_base.py | 51 ++++--------------- .../test_zone_picking_set_line_destination.py | 4 +- 3 files changed, 19 insertions(+), 51 deletions(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 263b1565490..5e89b5822c7 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -187,12 +187,10 @@ def _response_for_set_line_destination( next_state="set_line_destination", data=data, message=message ) - def _response_for_zero_check(self, location, message=None): - return self._response( - next_state="zero_check", - data=self._data_for_location(location), - message=message, - ) + def _response_for_zero_check(self, move_line, message=None): + data = self._data_for_location(move_line.location_id) + data["move_line"] = self.data.move_line(move_line) + return self._response(next_state="zero_check", data=data, message=message,) def _response_for_change_pack_lot(self, move_line, message=None): return self._response( @@ -612,7 +610,7 @@ def _set_destination_location(self, move_line, quantity, confirmation, location) # Zero check zero_check = self.picking_type.shopfloor_zero_check if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): - response = self._response_for_zero_check(move_line.location_id) + response = self._response_for_zero_check(move_line) return (location_changed, response) def _is_package_empty(self, package): @@ -686,7 +684,7 @@ def _set_destination_package(self, move_line, quantity, package): # Zero check zero_check = self.picking_type.shopfloor_zero_check if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): - response = self._response_for_zero_check(move_line.location_id) + response = self._response_for_zero_check(move_line) return (package_changed, response) def set_destination( @@ -1463,5 +1461,6 @@ def _schema_for_zero_check(self): "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()), + "move_line": self.schemas._schema_dict_of(self.schemas.move_line()), } return schema diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index a8e8e6f10ea..09b2c700114 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -260,10 +260,7 @@ 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): @@ -276,10 +273,7 @@ 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( @@ -314,11 +308,7 @@ 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( @@ -382,13 +372,7 @@ 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, move_line, message=None, ): self.assert_response( response, @@ -396,36 +380,26 @@ def _assert_response_zero_check( data={ "zone_location": self.data.location(zone_location), "picking_type": self.data.picking_type(picking_type), - "location": self.data.location(location), + "location": self.data.location(move_line.location_id), + "move_line": self.data.move_line(move_line), }, message=message, ) def assert_response_zero_check( - self, - response, - zone_location, - picking_type, - location, - message=None, + self, response, zone_location, picking_type, move_line, message=None, ): self._assert_response_zero_check( "zero_check", response, zone_location, picking_type, - location, + move_line, message=message, ) 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, @@ -439,12 +413,7 @@ 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_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index 3e1ee753b93..5bd1a1a122a 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -342,7 +342,7 @@ 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 ) def test_set_destination_package_full_qty(self): @@ -492,5 +492,5 @@ 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, ) From 16397f917531fd721760f95ca4f6e2df225c3f89 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 28 Jan 2021 09:02:25 +0100 Subject: [PATCH 500/710] shopfloor: bump 13.0.2.2.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index c437b916870..20a68c638da 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.2.2.0", + "version": "13.0.2.2.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From a41ddb34d96ed51dca147fd1c8af292c41247ef8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 27 Jan 2021 15:03:26 +0100 Subject: [PATCH 501/710] shopfloor: remove dead vars in tests --- .../test_zone_picking_change_pack_lot.py | 21 +++---------------- .../test_zone_picking_select_picking_type.py | 8 +------ .../tests/test_zone_picking_unload_all.py | 7 +------ ...est_zone_picking_unload_set_destination.py | 2 -- 4 files changed, 5 insertions(+), 33 deletions(-) diff --git a/shopfloor/tests/test_zone_picking_change_pack_lot.py b/shopfloor/tests/test_zone_picking_change_pack_lot.py index 107829fd448..7e080c90e20 100644 --- a/shopfloor/tests/test_zone_picking_change_pack_lot.py +++ b/shopfloor/tests/test_zone_picking_change_pack_lot.py @@ -19,12 +19,7 @@ def test_change_pack_lot_no_package_or_lot_for_barcode(self): 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, - }, + params={"move_line_id": move_line.id, "barcode": barcode}, ) self.assert_response_change_pack_lot( response, @@ -49,12 +44,7 @@ def test_change_pack_lot_change_pack_ok(self): # 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, - }, + params={"move_line_id": move_line.id, "barcode": self.free_package.name}, ) # check data self.assertRecordValues( @@ -115,12 +105,7 @@ def test_change_pack_lot_change_lot_ok(self): # 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, - }, + params={"move_line_id": move_line.id, "barcode": self.free_lot.name}, ) # check data self.assertRecordValues(move_line, [{"lot_id": self.free_lot.id}]) diff --git a/shopfloor/tests/test_zone_picking_select_picking_type.py b/shopfloor/tests/test_zone_picking_select_picking_type.py index 25657004be0..e2639db1f3e 100644 --- a/shopfloor/tests/test_zone_picking_select_picking_type.py +++ b/shopfloor/tests/test_zone_picking_select_picking_type.py @@ -13,13 +13,7 @@ class ZonePickingSelectPickingTypeCase(ZonePickingCommonCase): 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, - }, - ) + response = self.service.dispatch("list_move_lines", params={},) move_lines = self.service._find_location_move_lines(zone_location, picking_type) self.assert_response_select_line( response, diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index 3f8ae15d8c1..87f53036572 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -34,12 +34,7 @@ def test_set_destination_all_different_destination(self): ) # 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, - }, + "set_destination_all", params={"barcode": self.packing_location.barcode}, ) # check response buffer_lines = self.service._find_buffer_move_lines() diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index d9ae3ae9637..bf0cf7bdf5c 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -104,8 +104,6 @@ def test_unload_set_destination_location_move_not_allowed(self): 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, }, From 19d2cbaacb6062f6dea158f4f104edb1b78e78cb Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 27 Jan 2021 15:01:59 +0100 Subject: [PATCH 502/710] shopfloor: zone picking unload all lines at once No matter in which zone the user is when starting to unload: get all processed moves by the same user to unload them all at once whenever possible. --- shopfloor/services/zone_picking.py | 15 ++- shopfloor/tests/__init__.py | 1 + .../test_zone_picking_unload_buffer_lines.py | 106 ++++++++++++++++++ 3 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 shopfloor/tests/test_zone_picking_unload_buffer_lines.py diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 5e89b5822c7..7d0891cedfb 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -346,7 +346,7 @@ def _find_location_move_lines( def _find_buffer_move_lines_domain(self, dest_package=None): domain = [ - ("location_id", "child_of", self.zone_location.id), + ("picking_id.picking_type_id", "in", self.picking_types.ids), ("qty_done", ">", 0), ("state", "not in", ("cancel", "done")), ("result_package_id", "!=", False), @@ -675,11 +675,7 @@ def _set_destination_package(self, move_line, quantity, package): 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 + self._set_move_line_as_done(move_line, quantity, package) package_changed = True # Zero check zero_check = self.picking_type.shopfloor_zero_check @@ -687,6 +683,13 @@ def _set_destination_package(self, move_line, quantity, package): response = self._response_for_zero_check(move_line) return (package_changed, response) + def _set_move_line_as_done(self, move_line, quantity, package, user=None): + 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 = user or self.env.user + def set_destination( self, move_line_id, barcode, quantity, confirmation=False, ): diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 3c67d70a693..49f639674ad 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -58,6 +58,7 @@ 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_buffer_lines 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_unload_buffer_lines.py b/shopfloor/tests/test_zone_picking_unload_buffer_lines.py new file mode 100644 index 00000000000..0944d96ae2c --- /dev/null +++ b/shopfloor/tests/test_zone_picking_unload_buffer_lines.py @@ -0,0 +1,106 @@ +# 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 + + +class ZonePickingUnloadBufferLinesCase(ZonePickingCommonCase): + """Tests buffer lines to unload are retrieved properly. + + Buffer lines are the lines processed during zone picking work. + At the end of her/his work, the user can unload all processed lines + in one or more destination. + + Here we make sure all the lines are processable. + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + + def test_find_buffer_lines1(self): + move_lines = ( + self.picking1.move_line_ids + | self.picking2.move_line_ids + | self.picking3.move_line_ids + | self.picking4.move_line_ids + ) + zones = move_lines.mapped("location_id") + # we work on lines coming from 4 different locations + self.assertEqual(len(zones), 4) + # Process them all (simulate) + for i, line in enumerate(move_lines): + dest_package = self.env["stock.quant.package"].create( + {"name": f"TEST PKG {i}"} + ) + self.service._set_destination_package( + line, line.product_uom_qty, dest_package, + ) + + # We can unload all the lines no matter which zone we are before unload + for zone in zones: + self.service.work.current_zone_location = zone + self.assertEqual(self.service._find_buffer_move_lines(), move_lines) + + def test_find_buffer_lines2(self): + # Skip lines from picking1 + move_lines = ( + self.picking2.move_line_ids + | self.picking3.move_line_ids + | self.picking4.move_line_ids + ) + zones = move_lines.mapped("location_id") + # we work on lines coming from 3 different locations + self.assertEqual(len(zones), 3) + # Process them all (simulate) + for i, line in enumerate(move_lines): + dest_package = self.env["stock.quant.package"].create( + {"name": f"TEST PKG {i}"} + ) + self.service._set_destination_package( + line, line.product_uom_qty, dest_package, + ) + + # We can unload all the lines no matter which zone we are before unload + for zone in zones: + self.service.work.current_zone_location = zone + self.assertEqual(self.service._find_buffer_move_lines(), move_lines) + self.assertNotIn( + self.picking1.move_line_ids, self.service._find_buffer_move_lines() + ) + + def test_find_buffer_lines3(self): + move_lines = ( + self.picking2.move_line_ids + | self.picking3.move_line_ids + | self.picking4.move_line_ids + ) + zones = move_lines.mapped("location_id") + # we work on lines coming from 4 different locations + self.assertEqual(len(zones), 3) + # Process them all (simulate) + for i, line in enumerate(move_lines): + dest_package = self.env["stock.quant.package"].create( + {"name": f"TEST PKG {i}"} + ) + self.service._set_destination_package( + line, line.product_uom_qty, dest_package, + ) + # Simulate line from picking1 processed by another user + for i, line in enumerate(self.picking1.move_line_ids): + dest_package = self.env["stock.quant.package"].create( + {"name": f"TEST PKG 1 {i}"} + ) + self.service._set_move_line_as_done( + line, + line.product_uom_qty, + dest_package, + user=self.env.ref("base.user_admin"), + ) + + # We can unload all the lines no matter which zone we are before unload + for zone in zones: + self.service.work.current_zone_location = zone + self.assertEqual(self.service._find_buffer_move_lines(), move_lines) + self.assertNotIn( + self.picking1.move_line_ids, self.service._find_buffer_move_lines() + ) From ac9e28aa17108f40fcce2e58c795f6b3e396d246 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 Jan 2021 14:54:06 +0100 Subject: [PATCH 503/710] shopfloor: bump 13.0.2.3.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 20a68c638da..e2b0da6a780 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.2.2.1", + "version": "13.0.2.3.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From b44e8536ca48d89aa8bbac69388d54d16848ffdf Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 20 Jan 2021 12:15:11 +0100 Subject: [PATCH 504/710] Add validator handlers delegating to component validators Following commit 81b9cfb3 which was a work-around, a new way to customize the handlers for validators is added in base_rest: https://github.com/OCA/rest-framework/pull/99 This commit uses the new component to dispatch the validators to shopfloor's validator components. --- shopfloor/services/service.py | 38 ------------ shopfloor/services/validator.py | 103 +++++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 40 deletions(-) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index aae3a9c231c..71911488cde 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -14,7 +14,6 @@ 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): @@ -197,43 +196,6 @@ def _to_json(self, records): res.append(self._convert_one_record(record)) return res - def _get_validator_schema(self, method_name, usage_suffix): - validator_component = self.component( - usage="{}.{}".format(self._usage, usage_suffix) - ) - 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 ): diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py index b26d0113754..717fd9296f2 100644 --- a/shopfloor/services/validator.py +++ b/shopfloor/services/validator.py @@ -1,6 +1,105 @@ -# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 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 +import logging + +from odoo.addons.component.core import AbstractComponent, Component +from odoo.addons.component.exception import NoComponentError + +_logger = logging.getLogger(__name__) + + +class ShopfloorRestCerberusValidator(Component): + """Customize the handling of validators + + 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.response" 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")], + input_param=restapi.CerberusValidator("_get_partner_input_schema"), + output_param=restapi.CerberusValidator("_get_partner_output_schema"), + auth="public", + ) + + The schema is get by calling the method "_get..." on the service. + + 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 # noqa + + The following change in base_rest allows to customize the way the validator + handler is get: https://github.com/OCA/rest-framework/pull/99 + + This is what is used here to delegate to our ".validator" and + ".validator.response" components. + """ + + _name = "shopfloor.rest.cerberus.validator" + _inherit = "base.rest.cerberus.validator" + _usage = "cerberus.validator" + _collection = "shopfloor.service" + _is_rest_service_component = False + + def _get_validator_component(self, service, method_name, direction): + assert direction in ("input", "output") + if direction == "input": + suffix = "validator" + method_name = method_name.replace("_validator_", "") + else: + suffix = "validator.response" + method_name = method_name.replace("_validator_return_", "") + validator_component = self.component( + usage="{}.{}".format(service._usage, suffix) + ) + return validator_component, method_name + + def get_validator_handler(self, service, method_name, direction): + """Get the validator handler for a method + + By default, it returns the method on the current service instance. It + can be customized to delegate the validators to another component. + """ + try: + validator_component, method_name = self._get_validator_component( + service, method_name, direction + ) + except NoComponentError: + _logger.warning("no component found for %s method %s", service, method_name) + return {} + + try: + return getattr(validator_component, method_name) + except AttributeError: + _logger.warning( + "no validator method found for %s method %s", service, method_name + ) + return {} + + def has_validator_handler(self, service, method_name, direction): + """Return if the service has a validator handler for a method + + By default, it returns True if the the method exists on the service. It + can be customized to delegate the validators to another component. + """ + try: + validator_component, method_name = self._get_validator_component( + service, method_name, direction + ) + except NoComponentError: + return False + return hasattr(validator_component, method_name) class BaseShopfloorValidator(AbstractComponent): From 00063b236b19f77589e386ed66b131e5178a7225 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 28 Jan 2021 14:58:16 +0100 Subject: [PATCH 505/710] shopfloor: bump 13.0.2.4.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index e2b0da6a780..435ba18191e 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.2.3.0", + "version": "13.0.2.4.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From e9300d5b1b49e98e3a469f7ab1e7538ee1bfdb27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 25 Jan 2021 17:03:40 +0100 Subject: [PATCH 506/710] [FIX] shopfloor: handle backorders When moves are validated among all scenarios, we are validating related picking the standard way (with 'action_done()') when: 1) the moves to process are equal to all assigned moves of the current picking (so we can have waiting moves in the picking, in that case a normal backorder will be created by 'action_done()'), in other terms it is triggered when we are validating the last moves related to one picking, 2) the moves to process are linked to unprocessed ancestor/source moves. This condition is required because we want to avoid some business logic bound to the `_create_backorder` standard method, like the backorder strategy configured through the `stock_picking_backorder_strategy` module. In all other cases, we want to process the current moves in their own picking (use of `extract_and_action_done()` method), letting the current picking `confirmed/waiting` with the remaining qties. Point 1) was already implemented, this commit implements the point 2). Also, location content transfer scenario was not using the 'validate_moves()' method (already used by all scenarios to validate moves), this commit is fixing that. --- shopfloor/actions/stock.py | 14 ++- .../services/location_content_transfer.py | 3 +- ...on_content_transfer_set_destination_all.py | 94 +++++++++++++++++++ 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/shopfloor/actions/stock.py b/shopfloor/actions/stock.py index 54e9f0b8487..7c7d4d0dd3e 100644 --- a/shopfloor/actions/stock.py +++ b/shopfloor/actions/stock.py @@ -32,10 +32,14 @@ def validate_moves(self, moves): 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) + We want to create a normal backorder if: + + - the moves are equal to all available moves of the current picking + but there are still unavailable moves to process + - the moves are not linked to unprocessed ancestor moves """ assigned_moves = picking.move_lines.filtered(lambda m: m.state == "assigned") - return moves == assigned_moves + has_ancestors = bool( + moves.move_orig_ids.filtered(lambda m: m.state not in ("cancel", "done")) + ) + return moves == assigned_moves and not has_ancestors diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index ba4e92e4eaf..66b4237f26a 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -421,7 +421,8 @@ def _write_destination_on_lines(self, lines, 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() + stock = self.actions_for("stock") + stock.validate_moves(move_lines.move_id) def _lock_lines(self, lines): """Lock move lines""" 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 5edc6ad50b4..89b2b0cd2ff 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -81,6 +81,100 @@ def test_set_destination_all_dest_location_ok(self): ) self.assert_all_done(sub_shelf1) + def test_set_destination_all_with_partially_available_move_without_ancestor(self): + """Scanned destination location valid, but one of the move to process + is partially available and has no ancestor move. + + In such case, normal backorder is created with the remaining qty while + the current pickings is validated. + """ + # Put a partial quantity for 'product_d' to get a partially available move + self.picking2.do_unreserve() + self._update_qty_in_location(self.content_loc, self.product_d, 5) + self.picking2.action_assign() + self._simulate_pickings_selected(self.picking2) + move_d = self.picking2.move_lines.filtered( + lambda m: m.product_id == self.product_d + ) + + 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=self.service.msg_store.location_content_transfer_complete( + self.content_loc, sub_shelf1 + ), + ) + # As we have no ancestor move in progress, a normal backorder is created + # with the remaining qties + self.assertEqual(self.picking2.state, "done") + self.assertEqual(move_d.state, "done") + self.assertEqual(move_d.product_qty, 5) + self.assertTrue(self.picking2.backorder_ids) + self.assertNotEqual(self.picking2.backorder_ids.state, "done") + self.assertEqual(self.picking2.backorder_ids.move_lines.product_qty, 5) + + def test_set_destination_all_with_partially_available_move_with_ancestor(self): + """Scanned destination location valid, but one of the move to process + is partially available and has an unprocessed ancestor move. + + In such case, new picking is created to validate the moves, and the + remaining qties stay in their current picking. + """ + # Put a partial quantity for 'product_d' to get a partially available move + self.picking2.do_unreserve() + self._update_qty_in_location(self.content_loc, self.product_d, 5) + self.picking2.action_assign() + self._simulate_pickings_selected(self.picking2) + # Set an ancestor move on the partially available move + move_d = self.picking2.move_lines.filtered( + lambda m: m.product_id == self.product_d + ) + move_d.move_orig_ids |= move_d.copy({"picking_id": False}) + + 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=self.service.msg_store.location_content_transfer_complete( + self.content_loc, sub_shelf1 + ), + ) + # The current picking with the remaining qties is waiting (because of the + # ancestor move), and other moves are validated in a new one. + self.assertEqual(self.picking2.state, "waiting") + self.assertEqual(self.picking2.move_lines.state, "waiting") + self.assertEqual(self.picking2.move_lines.product_qty, 5) + self.assertEqual(self.picking2.backorder_ids.state, "done") + self.assertEqual(move_d.state, "done") + self.assertEqual(move_d.product_qty, 5) + 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. From 062a32a06aadfc15de841b11f1f0ddaa4c48c5d4 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 28 Jan 2021 15:00:20 +0100 Subject: [PATCH 507/710] shopfloor: bump 13.0.2.4.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 435ba18191e..d9bdd5168ba 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.2.4.0", + "version": "13.0.2.4.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 7990e0ae07dbac17935ff576509c059b245195a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 14 Jan 2021 10:57:01 +0100 Subject: [PATCH 508/710] [IMP] shopfloor: use estimated package weight if the weight is not set --- shopfloor/__manifest__.py | 2 +- shopfloor/actions/data.py | 8 ++++++-- shopfloor/models/stock_quant_package.py | 12 ++++++++++++ shopfloor/services/checkout.py | 4 +++- shopfloor/services/cluster_picking.py | 7 ++++++- shopfloor/tests/test_actions_data.py | 14 +++++--------- shopfloor/tests/test_actions_data_detail.py | 8 ++++---- shopfloor/tests/test_checkout_list_package.py | 5 ++++- 8 files changed, 41 insertions(+), 19 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index d9bdd5168ba..ee3315f1bb8 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -23,7 +23,7 @@ "auth_api_key", # OCA / stock-logistics-warehouse "stock_picking_completion_info", - # OCA / stock-logistics-warehouse + # OCA / stock-logistics-workflow "stock_quant_package_dimension", # OCA / stock-logistics-warehouse "stock_quant_package_product_packaging", diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 540148a6435..e36714daf20 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -116,7 +116,7 @@ def _package_parser(self): return [ "id", "name", - "pack_weight:weight", + "shopfloor_weight:weight", ("package_storage_type_id:storage_type", ["id", "name"]), ] @@ -169,7 +169,11 @@ def move_line(self, record, with_picking=False, **kw): record.package_id, record.picking_id, **kw ), "package_dest": self.package( - record.result_package_id, record.picking_id, **kw + record.result_package_id.with_context( + picking_id=record.picking_id.id + ), + record.picking_id, + **kw, ), } ) diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py index c155115bcaf..f54c4097c01 100644 --- a/shopfloor/models/stock_quant_package.py +++ b/shopfloor/models/stock_quant_package.py @@ -23,6 +23,12 @@ class StockQuantPackage(models.Model): comodel_name="stock.move.line", compute="_compute_reserved_move_lines", ) + shopfloor_weight = fields.Float( + "Shopfloor weight (kg)", + digits="Product Unit of Measure", + compute="_compute_shopfloor_weight", + help="Real pack weight or the estimated one.", + ) def _get_reserved_move_lines(self): return self.env["stock.move.line"].search( @@ -34,6 +40,12 @@ def _compute_reserved_move_lines(self): for rec in self: rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()}) + @api.depends("pack_weight", "estimated_pack_weight") + @api.depends_context("picking_id") + def _compute_shopfloor_weight(self): + for rec in self: + rec.shopfloor_weight = rec.pack_weight or rec.estimated_pack_weight + # TODO: we should refactor this like # source_planned_move_line_ids diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 6605c3bc6a5..4074e4e2fe0 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -103,7 +103,9 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None): ) picking_data = self.data.picking(picking) packages_data = self.data.packages( - packages.sorted(), picking=picking, with_packaging=True + packages.with_context(picking_id=picking.id).sorted(), + picking=picking, + with_packaging=True, ) return self._response( next_state="select_dest_package", diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 11022ff31bb..c467a75bd41 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -104,7 +104,12 @@ 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.package(last_picked_line.result_package_id) + data["package_dest"] = self.data.package( + last_picked_line.result_package_id.with_context( + picking_id=move_line.picking_id.id + ), + picking=move_line.picking_id, + ) return self._response(next_state="scan_destination", data=data, message=message) def _response_for_change_pack_lot(self, move_line, message=None): diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 6215ea5e8ec..55bec2d1a74 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -162,7 +162,7 @@ def _expected_package(self, record, **kw): data = { "id": record.id, "name": record.name, - "weight": record.pack_weight, + "weight": record.pack_weight or record.estimated_pack_weight, "storage_type": None, } data.update(kw) @@ -213,7 +213,7 @@ def test_data_package(self): "storage_type": self._expected_storage_type( package.package_storage_type_id ), - "weight": 0.0, + "weight": 20.0, } self.assertDictEqual(data, expected) @@ -296,16 +296,14 @@ def test_data_move_line_package(self): "id": move_line.package_id.id, "name": move_line.package_id.name, "move_line_count": 1, - # TODO - "weight": 0.0, + "weight": 20.0, "storage_type": None, }, "package_dest": { "id": result_package.id, "name": result_package.name, "move_line_count": 0, - # TODO - "weight": 0.0, + "weight": 6.0, "storage_type": None, }, "location_src": self._expected_location(move_line.location_id), @@ -354,15 +352,13 @@ def test_data_move_line_package_lot(self): "id": move_line.package_id.id, "name": move_line.package_id.name, "move_line_count": 1, - # TODO - "weight": 0, + "weight": 30, "storage_type": None, }, "package_dest": { "id": move_line.result_package_id.id, "name": move_line.result_package_id.name, "move_line_count": 1, - # TODO "weight": 0, "storage_type": None, }, diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index a87ad57fc94..f559eb2f061 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -142,7 +142,7 @@ def test_data_package(self): "name": package.name, "move_line_count": 1, "packaging": self.data_detail.packaging(package.packaging_id), - "weight": 0, + "weight": 20, "pickings": self.data_detail.pickings(pickings), "move_lines": self.data_detail.move_lines(lines), "storage_type": { @@ -205,14 +205,14 @@ def test_data_move_line_package(self): "id": move_line.package_id.id, "name": move_line.package_id.name, "move_line_count": 1, - "weight": 0.0, + "weight": 20.0, "storage_type": None, }, "package_dest": { "id": result_package.id, "name": result_package.name, "move_line_count": 0, - "weight": 0.0, + "weight": 6.0, "storage_type": None, }, "location_src": self._expected_location(move_line.location_id), @@ -263,7 +263,7 @@ def test_data_move_line_package_lot(self): "id": move_line.package_id.id, "name": move_line.package_id.name, "move_line_count": 1, - "weight": 0.0, + "weight": 30.0, "storage_type": None, }, "package_dest": { diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index e32fcf16d6a..ec26b756db9 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -23,7 +23,10 @@ def _assert_response_select_dest_package( "scheduled_date": picking.scheduled_date.isoformat() + "+00:00", }, "packages": [ - self._package_data(package, picking) for package in packages + self._package_data( + package.with_context(picking_id=picking.id), picking + ) + for package in packages ], "selected_move_lines": [ self._move_line_data(ml) for ml in selected_lines.sorted() From c72dca9cb09c1753863f172b05868d2d588fa3a2 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Fri, 29 Jan 2021 07:51:40 +0000 Subject: [PATCH 509/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index cf30226a3dc..6f2440c3c44 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -838,6 +838,11 @@ msgstr "" msgid "Quants" msgstr "" +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Real pack weight or the estimated one." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/services/scan_anything.py:0 #, python-format @@ -1058,6 +1063,11 @@ msgstr "" msgid "Shopfloor profile settings" msgstr "" +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Shopfloor weight (kg)" +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 From 826f205fad1afd6e69767517e64ebcae1fff807a Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 29 Jan 2021 14:33:40 +0000 Subject: [PATCH 510/710] shopfloor 13.0.2.5.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index ee3315f1bb8..fb65fca6d02 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.2.4.1", + "version": "13.0.2.5.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 2d11986c5ea4c81f2ecc06b830ebb320c8a5724b Mon Sep 17 00:00:00 2001 From: Carlos Serra-Toro Date: Wed, 3 Feb 2021 14:07:22 +0100 Subject: [PATCH 511/710] [ADD] shopfloor_packing_info: Messages for Checkout Packing Info This PR adds the option to choose predefined messages for the parameter "Checkout Packing Info", that is set on the res.partner and reused in other places. For this, the original text field shopfloor_packing_info has been transformed into a Many2one that points to the new model shopfloor.packing.info. Thus, the field in the res.partner has been renamed to shopfloor_packing_info_id, that has caused some renames in other files that used it. These new messages are archived instead of deleted, to keep track of old messages associated to existing (or archived) res.partners. This new model, shopfloor.packing.info has been moved to the new module shopfloor_packing_info. Migration hooks have been provided to migrate the data, that will happen if the old module is updated at the same time in which the new module is installed. The new predefined messages can be created directly in the res.partner or in the menu Inventory > Settings > Shopfloor. To migrate the data, the existing data has been migrated by assuming that the first line is the name while all the content is for the text of the new model. --- shopfloor/__manifest__.py | 2 -- shopfloor/models/__init__.py | 1 - shopfloor/models/res_partner.py | 9 ----- shopfloor/models/stock_picking.py | 7 ---- shopfloor/models/stock_picking_type.py | 5 --- shopfloor/services/checkout.py | 7 ++-- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_checkout_scan_line.py | 23 +++---------- .../tests/test_checkout_scan_line_base.py | 18 ++++++++++ .../test_checkout_select_package_base.py | 11 ++---- shopfloor/views/res_partner.xml | 15 -------- shopfloor/views/stock_picking_type.xml | 4 --- shopfloor/views/stock_picking_views.xml | 34 ------------------- 13 files changed, 31 insertions(+), 106 deletions(-) delete mode 100644 shopfloor/models/res_partner.py create mode 100644 shopfloor/tests/test_checkout_scan_line_base.py delete mode 100644 shopfloor/views/res_partner.xml delete mode 100644 shopfloor/views/stock_picking_views.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index fb65fca6d02..51f122c58ec 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -46,12 +46,10 @@ "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 30930739fd4..cb9a9dd4066 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -1,5 +1,4 @@ from . import priority_postpone_mixin -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 deleted file mode 100644 index 35111600bbb..00000000000 --- a/shopfloor/models/res_partner.py +++ /dev/null @@ -1,9 +0,0 @@ -# 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 - - -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 e37e3ff67de..2394a79ce25 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -14,13 +14,6 @@ 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", - ) - shopfloor_packing_info = fields.Text( - string="Packing information", - related="partner_id.shopfloor_packing_info", - ) @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 984a69cb8c0..83a636df738 100644 --- a/shopfloor/models/stock_picking_type.py +++ b/shopfloor/models/stock_picking_type.py @@ -17,11 +17,6 @@ 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/services/checkout.py b/shopfloor/services/checkout.py index 4074e4e2fe0..654e2963198 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -84,8 +84,11 @@ def _response_for_select_package(self, picking, lines, message=None): ) def _data_for_packing_info(self, picking): - if picking.picking_type_id.shopfloor_display_packing_info: - return picking.shopfloor_packing_info or "" + """Return the packing information + + Intended to be extended. + """ + # TODO: This could be avoided if included in the picking parser. return "" def _response_for_select_dest_package(self, picking, move_lines, message=None): diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 49f639674ad..0a5575d4681 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -23,6 +23,7 @@ from . import test_checkout_scan from . import test_checkout_select from . import test_checkout_scan_line +from . import test_checkout_scan_line_base from . import test_checkout_select_line from . import test_checkout_select_package_base from . import test_checkout_set_qty diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py index bedc37a8bf6..7eb4780dac2 100644 --- a/shopfloor/tests/test_checkout_scan_line.py +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -1,22 +1,9 @@ # 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 +from .test_checkout_scan_line_base import CheckoutScanLineCaseBase -class CheckoutScanLineCase(CheckoutCommonCase, CheckoutSelectPackageMixin): - 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 - :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, packing_info=packing_info) - +class CheckoutScanLineCase(CheckoutScanLineCaseBase): def test_scan_line_package_ok(self): picking = self._create_picking( lines=[(self.product_a, 10), (self.product_b, 10)] @@ -31,12 +18,10 @@ 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): + def test_scan_line_package_ok_packing_info_empty_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 @@ -45,7 +30,7 @@ def test_scan_line_package_ok_packing_info(self): 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) + 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( diff --git a/shopfloor/tests/test_checkout_scan_line_base.py b/shopfloor/tests/test_checkout_scan_line_base.py new file mode 100644 index 00000000000..42aaf91993a --- /dev/null +++ b/shopfloor/tests/test_checkout_scan_line_base.py @@ -0,0 +1,18 @@ +# Copyright 2021 Camptocamp SA (https://www.camptocamp.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +class CheckoutScanLineCaseBase(CheckoutCommonCase, CheckoutSelectPackageMixin): + def _test_scan_line_ok(self, barcode, selected_lines, packing_info=""): + """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, packing_info=packing_info) diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py index f361b26fd89..4b3abd6dc07 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( response, selected_lines, message=None, - packing_info=False, + packing_info="", no_package_enabled=True, ): picking = selected_lines.mapped("picking_id") @@ -20,19 +20,14 @@ def _assert_selected_response( 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 "", + "packing_info": packing_info, "no_package_enabled": no_package_enabled, }, message=message, ) def _assert_selected_qties( - self, - response, - selected_lines, - lines_quantities, - message=None, - packing_info=False, + self, response, selected_lines, lines_quantities, message=None, packing_info="", ): picking = selected_lines.mapped("picking_id") deselected_lines = picking.move_line_ids - selected_lines diff --git a/shopfloor/views/res_partner.xml b/shopfloor/views/res_partner.xml deleted file mode 100644 index 3152277af67..00000000000 --- a/shopfloor/views/res_partner.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - partner.shopfloor.form - res.partner - - - - - - - - - - diff --git a/shopfloor/views/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml index 81b809c27a6..ad90a59d5b9 100644 --- a/shopfloor/views/stock_picking_type.xml +++ b/shopfloor/views/stock_picking_type.xml @@ -12,10 +12,6 @@ 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 deleted file mode 100644 index 308071f2664..00000000000 --- a/shopfloor/views/stock_picking_views.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - stock.picking.shopfloor.form - stock.picking - - - - - - - - - - - - - - - - - From c7a3a0eb3b18aeb501ca8105ba01763af813fc78 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Mon, 1 Feb 2021 11:43:09 +0100 Subject: [PATCH 512/710] shopfloor: Improve package and product details Adds on the package details: * the actual stock location of the package * the barcode of the product * the weight unit (kg) And also add the barcode on the product details. --- shopfloor/actions/data_detail.py | 1 + shopfloor/services/schema_detail.py | 1 + 2 files changed, 2 insertions(+) diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py index ddd7527802f..d25127aab2e 100644 --- a/shopfloor/actions/data_detail.py +++ b/shopfloor/actions/data_detail.py @@ -73,6 +73,7 @@ def _package_detail_parser(self): "reserved_move_line_ids:move_lines", lambda record, fname: self.move_lines(record[fname]), ), + ("location_id:location", ["id", "display_name:name"]), ] def lot_detail(self, record, **kw): diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py index 934c27b3d44..cb4e10b0385 100644 --- a/shopfloor/services/schema_detail.py +++ b/shopfloor/services/schema_detail.py @@ -51,6 +51,7 @@ def package_detail(self): { "pickings": self._schema_list_of(self.picking()), "move_lines": self._schema_list_of(self.move_line()), + "location": self._schema_dict_of(self._simple_record()), } ) return schema From b1555f98b77c25efb8779e81d4aca45c1cd0cc4c Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Thu, 4 Feb 2021 10:58:03 +0100 Subject: [PATCH 513/710] shopfloor: Fix tests --- shopfloor/tests/test_actions_data_detail.py | 8 ++++++-- shopfloor/tests/test_scan_anything.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index f559eb2f061..d198d9a6bc5 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -80,7 +80,7 @@ def _expected_product_detail(self, record, **kw): return dict(**self._expected_product(record), **detail) -class ActionsDataDetailCase(ActionsDataDetailCaseBase): +class TestActionsDataDetailCase(ActionsDataDetailCaseBase): def test_data_location(self): location = self.stock_location data = self.data_detail.location_detail(location) @@ -139,10 +139,14 @@ def test_data_package(self): pickings = lines.mapped("picking_id") expected = { "id": package.id, + "location": { + "id": package.location_id.id, + "name": package.location_id.display_name, + }, "name": package.name, "move_line_count": 1, "packaging": self.data_detail.packaging(package.packaging_id), - "weight": 20, + "weight": 20.0, "pickings": self.data_detail.pickings(pickings), "move_lines": self.data_detail.move_lines(lines), "storage_type": { diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py index 3e49d472ed8..7b2e9d4334a 100644 --- a/shopfloor/tests/test_scan_anything.py +++ b/shopfloor/tests/test_scan_anything.py @@ -4,7 +4,7 @@ from .test_actions_data_detail import ActionsDataDetailCaseBase -class ScanAnythingCase(ActionsDataDetailCaseBase): +class TestScanAnythingCase(ActionsDataDetailCaseBase): def setUp(self): super().setUp() with self.work_on_services() as work: From 56b2a98416e7f2b17388bcea15c938dc31429c32 Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Sat, 6 Feb 2021 20:07:17 +0000 Subject: [PATCH 514/710] Added translation using Weblate (Spanish (Argentina)) --- shopfloor/i18n/es_AR.po | 527 +++++++++++++++++++--------------------- 1 file changed, 252 insertions(+), 275 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index ac41377758e..5f5b2f4b411 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -6,65 +6,63 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 13.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2021-02-06 22:44+0000\n" -"Last-Translator: Ignacio Buioli \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" -"X-Generator: Weblate 4.3.2\n" #. module: shopfloor #: code:addons/shopfloor/services/forms/form_mixin.py:0 #, python-format msgid "%s updated." -msgstr "%s actualizado." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "A destination package is required." -msgstr "Un paquete de destino es requerido." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 #, python-format msgid "A draft inventory has been created for control." -msgstr "Se ha creado un borrador de inventario para su control." +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check msgid "Activate Zero Check" -msgstr "Activar Verificación Cero" +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 "Activo" +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin msgid "Adds shopfloor priority/postpone fields" -msgstr "Agrega campos de prioridad / aplazamiento del taller" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "All packages processed." -msgstr "Todos los paquetes procesados." +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create msgid "Allow Move Creation" -msgstr "Permitir Creación de Movimiento" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves msgid "Allow to process reserved quantities" -msgstr "Permitir procesar cantidades reservadas" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view @@ -72,121 +70,121 @@ msgstr "Permitir procesar cantidades reservadas" #: 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 "Archivado" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Are you sure?" -msgstr "¿Está seguro?" +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 "Eliminación Automática de Registros del Taller" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Barcode does not match with {}." -msgstr "Código de barras no coincide con {}." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Barcode not found" -msgstr "Código de barras no encontrado" +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_picking_batch msgid "Batch Transfer" -msgstr "Transferencia por Lotes" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Batch Transfer complete" -msgstr "Transferencia por Lotes completa" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Batch Transfer line done" -msgstr "Línea de Transferencia por lotes hecha" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Bin %s doesn't exist" -msgstr "Compartimento %s no existe" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Canceled, you can scan a new pack." -msgstr "Cancelado, puede escanear un nuevo paquete." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Cannot change to lot {} which is entirely picked." -msgstr "No se puede cambiar al lote {} ya que está completamente recogido." +msgstr "" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_checkout #: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo msgid "Checkout" -msgstr "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 "Información del Paquete del Checkout" +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 "Grupo de Picking" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Confirm location change from %s to %s?" -msgstr "¿Confirma cambiar ubicación desde %s hacia %s?" +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_res_partner msgid "Contact" -msgstr "Contacto" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Content transfer to {} completed" -msgstr "Transferencia de contenido a {} completada" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Content transferred from {} to {}." -msgstr "Transferencia de contenido desde {} hacia {}." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format msgid "Control stock issue in location {} for {}" -msgstr "Error en control de inventario en ubicación {} para {}" +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 "Creado por" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/models/stock_move.py:0 @@ -195,32 +193,30 @@ 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 "Creado el" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 #, python-format msgid "Creation of moves is not allowed for menu {}." -msgstr "La creación de movimientos no está permitida para el menú {}." +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Date" -msgstr "Fecha" +msgstr "" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery #: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo msgid "Delivery" -msgstr "Entrega" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__display_name @@ -228,41 +224,41 @@ msgstr "Entrega" #: 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 "Mostrar Nombre" +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 "Mostrar información del empaquetado de cliente" +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 "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 "Excepción" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_message msgid "Exception Message" -msgstr "Mensaje de Excepción" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Exception message" -msgstr "Mensaje de Excepción" +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 "Fallido" +msgstr "" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check @@ -271,9 +267,6 @@ 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 @@ -282,33 +275,32 @@ 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 "Desde" +msgstr "" #. module: shopfloor #: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__functional msgid "Functional" -msgstr "Funcional" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Functional errors" -msgstr "Errores funcionales" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view msgid "Group By" -msgstr "Agrupar por" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__headers msgid "Headers" -msgstr "Cabeceras" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id @@ -316,7 +308,7 @@ msgstr "Cabeceras" #: 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 "ID" +msgstr "" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available @@ -325,9 +317,6 @@ 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 @@ -335,34 +324,32 @@ 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 "Ignorar que No Hay Almacenamiento Disponible Es Posible" +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 "Ignora las transferencias cuando no haya disponibilidad de ubicación" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 #, python-format msgid "Ignoring not found putaway is not allowed for menu {}." -msgstr "No se permite ignorar el almacenamiento no encontrado para el menú {}." +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_inventory msgid "Inventory" -msgstr "Inventario" +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_location msgid "Inventory Locations" -msgstr "Ubicaciones de Inventario" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log____last_update @@ -370,21 +357,21 @@ msgstr "Ubicaciones de Inventario" #: 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 "Última Modificación el" +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 "Última Actualización por" +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 "Última Actualización el" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/completion_info.py:0 @@ -392,226 +379,219 @@ msgstr "Última Actualización el" 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 "Línea cancelada" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Lines have different destination location." -msgstr "La líneas tiene diferente ubicación de destino." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Location %s doesn't contain any package." -msgstr "La Ubicación %s no contiene ningún paquete." +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 "Transferencia de Contenido de Ubicación" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Location not allowed here." -msgstr "La Ubicación no está permitida aquí." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Location {} empty" -msgstr "Ubicación {} vacía" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Logs generated today" -msgstr "Registros generados hoy" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Lot is not in the current transfer." -msgstr "El Lote no está en la transferencia actual." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/delivery.py:0 #, python-format msgid "Lot {} belongs to a picking without a valid state." -msgstr "El Lote {} pertenece a un picking sin estado válido." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Lot {} is for another product." -msgstr "El Lote {} es para otro producto." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Lot {} replaced by lot {}." -msgstr "Lote {} reemplazado por lote {}." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format msgid "Lot: " -msgstr "Lote: " +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_shopfloor_menu msgid "Menu displayed in the scanner application" -msgstr "Menú mostrado en la aplicación de escaner" +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 "Menús" +msgstr "" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_profile__menu_ids msgid "Menus visible for this profile" -msgstr "Menús visibles para este perfil" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible msgid "Move Create Is Possible" -msgstr "Crear Movimiento es Posible" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids msgid "Move Line" -msgstr "Línea de Movimiento" +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 "Cuenta de Línea de Movimiento" +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 "" -"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 "Nombre" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Negative quantity not allowed." -msgstr "Cantidad negativa no permitida." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "New move lines cannot be assigned: canceled." -msgstr "Los nuevos movimientos de líneas no puede ser asignados: cancelados." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No lines to process." -msgstr "No hay líneas para procesar." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No location found for this barcode." -msgstr "No se encontró ubicación para este código de barras." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No lot found among current transfers." -msgstr "No se encontró lote perteneciente a transferencias actuales." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No lot found for {}" -msgstr "No se encontró lote para {}" +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 "No más trabajo por hacer, cree una nueva transferencia por lotes" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No operation type found for this menu and profile." -msgstr "No se ha encontrado ningún tipo de operación para este menú y perfil." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/service.py:0 #, python-format -msgid "No operation types configured on menu {} for warehouse {}." +msgid "No operation types configured on menu {}." 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 "No hay paquete o lote encontrado para el código de barras {}." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No pending operation for package %s." -msgstr "No hay operación pendiente para el paquete %s." +msgstr "" #. 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 "No hay ningún destino de almacenamiento disponible." +msgstr "" #. 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 "No hay paquete válido para seleccionar." +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 "No es un paquete de destino válido" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -620,8 +600,6 @@ 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 @@ -630,68 +608,70 @@ 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 "Tipos de Operación" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Operation already processed." -msgstr "Operación ya procesada." +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 "La operación ya está en marcha. ¿Le gustaría hacerse cargo?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__options +msgid "Options" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Package cancelled" -msgstr "Paquete cancelado" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package has been opened. You can move partial quantities." -msgstr "El paquete ha sido abierto. Puede mover cantidades parciales." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/location_content_transfer.py:0 #, python-format msgid "Package level has to be in draft" -msgstr "El paquete tiene que estar en Borrador" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/models/stock_quant_package.py:0 #, python-format msgid "Package name must be unique!" -msgstr "¡El nombre del Paquete debe ser único!" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/delivery.py:0 #, python-format msgid "Package {} belongs to a picking without a valid state." -msgstr "El Paquete {} pertenece a una entrega sin un estado válido." +msgstr "" #. 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 "El Paquete {} no puede ser usado: {} " +msgstr "" #. module: shopfloor #: code:addons/shopfloor/models/stock_move_line.py:0 @@ -699,164 +679,170 @@ msgstr "El Paquete {} no puede ser usado: {} " 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 "El Paquete {} tiene diferente contenido." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/models/stock_move_line.py:0 #, python-format msgid "Package {} has been partially picked in another location" -msgstr "El Paquete {} ha sido parcialmente entregado en otra ubicación" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package {} is already used." -msgstr "El Paquete {} está usado." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package {} is not available in transfer {}." -msgstr "El Paquete {} no está disponible en la transferencia {}." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package {} is not empty." -msgstr "El Paquete {} no está vacío." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Package {} is not in the current transfer." -msgstr "El Paquete {} no está en la transferencia actual." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Package {} replaced by package {}." -msgstr "El Paquete {} está reemplazado por el paquete {}." +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_quant_package msgid "Packages" -msgstr "Paquetes" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Packaging changed on package {}" -msgstr "El Empaquetado cambió en el paquete {}" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking__shopfloor_packing_info msgid "Packing information" -msgstr "Información del empaquetado" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view msgid "Parameters" -msgstr "Parámetros" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__params msgid "Params" -msgstr "Parámetros" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 #, python-format msgid "Pick: stock issue on lot: {} found in {}" -msgstr "Entrega: error de inventario en el lote: {} encontrado en {}" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count msgid "Picking Count" -msgstr "Cuenta de Entrega" +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_picking_type msgid "Picking Type" -msgstr "Tipo de Entrega" +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 "" -"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 "Tipo de Entrega {} completo." +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids msgid "Planned Move Line" -msgstr "Línea de Movimiento Planificada" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 #, python-format msgid "Processing reserved quantities is not allowed for menu {}." -msgstr "Procesar cantidades reservadas no está permitido para el menú {}." +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_move_line msgid "Product Moves (Stock Move Line)" -msgstr "Movimientos de Producto (Stock Move Line)" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Product is not in the current transfer." -msgstr "El Producto no está en la transferencia actual." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Product tracked by lot, please scan one." -msgstr "Producto rastreado por lote, por favor escanée uno." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/delivery.py:0 #, python-format msgid "Product {} belongs to a picking without a valid state." -msgstr "Producto {} pertenece a una entrega sin estado válido." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Product(s) packed in {}" -msgstr "Producto(s) empaquetado(s) en {}" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "Product(s) processed as raw product(s)" -msgstr "Producto(s) procesado(s) como producto(s) crudo(s)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__profile_id +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view +msgid "Profile" +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 "Perfiles" +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_quant msgid "Quants" -msgstr "Cantidades" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Real pack weight or the estimated one." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/scan_anything.py:0 @@ -865,88 +851,92 @@ 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 "Sesión anterior recuperada." +msgstr "" #. 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 "Método del Request" +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 "URL del Request" +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 "Movimiento de Línea Reservado" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Restart the operation, someone has canceled it." -msgstr "Reinicie la operación, alguien la ha cancelado." +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 "Resultado" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree msgid "SF Priority" -msgstr "Prioridad del Taller" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree msgid "SF checkout done" -msgstr "Checkout del Taller Hecho" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree msgid "SF unloaded" -msgstr "Taller Descargado" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Same package {} is already assigned." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Scan the destination location" -msgstr "Escanear la ubicación de destino" +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 "Escanear el paquete" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scenario +#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view msgid "Scenario" -msgstr "Escenario" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view msgid "Scenario Options" -msgstr "Opciones de Escenario" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 @@ -957,39 +947,35 @@ 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 "Secuencia" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Several lots found in %s, please scan a lot." -msgstr "Se han encontrado varios lotes en %s, escanee mucho." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Several operation types found for this menu and profile." -msgstr "Se han encontrado varios tipos de operaciones para este menú y perfil." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Several packages found in %s, please scan a package." -msgstr "Se han encontrado varios paquetes en %s, escanee un paquete." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Several products found in %s, please scan a product." -msgstr "Se han encontrado varios productos en %s, escanee un producto." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 @@ -998,24 +984,22 @@ 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 "Severo" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Severe errors" -msgstr "Errores severos" +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 "Severidad" +msgstr "" #. module: shopfloor #: model:ir.ui.menu,name:shopfloor.menu_shopfloor_settings @@ -1023,68 +1007,73 @@ msgstr "Severidad" #: 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 "Taller" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done msgid "Shopfloor Checkout Done" -msgstr "Checkout del Taller Hecho" +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_shopfloor_log msgid "Shopfloor Logging" -msgstr "Registro del Taller" +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 "Registros del Taller" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids msgid "Shopfloor Menus" -msgstr "Menús del Taller" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence msgid "Shopfloor Picking Sequence" -msgstr "Secuencia del Picking del Taller" +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 "Taller Pospuesto" +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 "Prioridad del Taller" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded msgid "Shopfloor Unloaded" -msgstr "Taller Descargado" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id msgid "Shopfloor User" -msgstr "Usuario del Taller" +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_shopfloor_profile msgid "Shopfloor profile settings" -msgstr "Ajustes del perfil de taller" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Shopfloor weight (kg)" +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 "Transferencia de un solo Palet" +msgstr "" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create @@ -1093,50 +1082,46 @@ 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 "Recurso del Movimiento de Línea" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id msgid "Source Package" -msgstr "Recurso del paquete" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__state msgid "State" -msgstr "Estado" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Status" -msgstr "Estado" +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_move msgid "Stock Move" -msgstr "Movimiento de Inventario" +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_package_level msgid "Stock Package Level" -msgstr "Nivel de Paquete de Existencias" +msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id msgid "Stock Picking" -msgstr "Inventario de la Entrega" +msgstr "" #. module: shopfloor #: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__success msgid "Success" -msgstr "Satisfactorio" +msgstr "" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed @@ -1146,35 +1131,33 @@ 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 "Campo técnico. Indica el número de líneas de movimiento incluidas." +msgstr "" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count msgid "Technical field. Indicates number of transfers included." -msgstr "Campo técnico. Indica el número de transferencias incluidas." +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 "Campo técnico. Indica el peso total de las transferencias incluidas." +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 "Campo técnico. Mueva las líneas para qué destino es este paquete." +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 "Campo técnico. Mueva líneas moviendo este paquete." +msgstr "" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority @@ -1182,8 +1165,6 @@ msgstr "Campo técnico. Mueva líneas moviendo este paquete." #: 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 @@ -1192,26 +1173,36 @@ msgid "" "The backorder %s has been created." msgstr "" -"Se ha creado el pedido pendiente %s." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The content of {} cannot be transferred with this scenario." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/cluster_picking.py:0 #, python-format msgid "The destination bin {} is not empty, please take another." -msgstr "El contenedor de destino {} no está vacío, tome otro." +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 "El paquete se ha movido, puede escanear un paquete nuevo." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s cannot be transferred with this scenario." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "The package %s doesn't exist" -msgstr "El paquete %s no existe" +msgstr "" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence @@ -1222,64 +1213,59 @@ 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 "El registro %s %s no existe" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "The record you were working on does not exist anymore." -msgstr "El registro en el que estaba trabajando ya no existe." +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 "La operación de inventario donde se ha realizado el embalaje" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/cluster_picking.py:0 #, python-format msgid "This batch cannot be selected." -msgstr "Este lote no se puede seleccionar." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This line has a package, please select the package instead." -msgstr "Esta línea tiene un paquete, seleccione el paquete en su lugar." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This line is not available in transfer {}." -msgstr "Esta línea no está disponible en la transferencia {}." +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 "El contenido de esta ubicación no se puede mover a la vez." +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 "El contenido de esta ubicación no se puede mover usando este menú." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This lot does not exist anymore." -msgstr "Este lote ya no existe." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -1287,31 +1273,30 @@ msgstr "Este lote ya no existe." 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 "Este lote es parte de varios paquetes, escanee un paquete." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This operation does not exist anymore." -msgstr "Esta operación ya no existe." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This package does not exist anymore." -msgstr "Este paquete ya no existe." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This product does not exist anymore." -msgstr "Este producto ya no existe." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -1320,143 +1305,137 @@ 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 "Este producto es parte de varios paquetes, escanee un paquete." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "This transfer does not exist or is not available anymore." -msgstr "Esta transferencia no existe o ya no está disponible." +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Today" -msgstr "Hoy" +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 "Peso Total" +msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_picking msgid "Transfer" -msgstr "Transferencia" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Transfer {} complete" -msgstr "Transferencia {} completa" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Transfer {} done" -msgstr "Transferencia {} realizada" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Transfer {} is not available." -msgstr "Transferencia {} no está disponible." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Units replaced by package {}." -msgstr "Unidades reemplazadas por paquete {}." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Unrecoverable error, please restart." -msgstr "Error irrecuperable, por favor reinicie." +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 "Es posible Anular la Reserva de Otros Movimientos" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "User" -msgstr "Usuario" - -#. module: shopfloor -#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_ids -msgid "Visible for these profiles" -msgstr "Visible para estos perfiles" +msgstr "" #. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_profile__warehouse_id -msgid "Warehouse" -msgstr "Almacén" +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_id +msgid "Visible on this profile only" +msgstr "" #. module: shopfloor #: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__warning msgid "Warning" -msgstr "Advertencia" +msgstr "" #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view msgid "Warning errors" -msgstr "Errores de advertencia" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/cluster_picking.py:0 #, python-format msgid "Wrong bin" -msgstr "Compartimento incorrecto" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "You cannot move this using this menu." -msgstr "No puede mover esto usando este menú." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "You cannot place it here" -msgstr "No puede colocarlo aquí" +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 "No puede trabajar en el paquete (%s) fuera de la ubicación: %s" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "You must not pick more than {} units." -msgstr "No debe seleccionar más de {} unidades." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format msgid "Zero check issue on location {}" -msgstr "Error de verificación cero en la ubicación {}" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format msgid "Zero check issue on location {} ({})" -msgstr "Error de verificación cero en la ubicación {} ({})" +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 "Zona de Entreda" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 @@ -1465,11 +1444,9 @@ 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 "{} {} poner en {}" +msgstr "" From 58e80571e57ccd3ab8e6128f4b32a548aaf34a4b Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Sat, 6 Feb 2021 20:09:37 +0000 Subject: [PATCH 515/710] Translated using Weblate (Spanish (Argentina)) Currently translated at 100.0% (241 of 241 strings) Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/es_AR/ --- shopfloor/i18n/es_AR.po | 489 ++++++++++++++++++++++------------------ 1 file changed, 273 insertions(+), 216 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 5f5b2f4b411..9889e5efda4 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:45+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,32 +282,34 @@ 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 #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_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 @@ -308,7 +317,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 @@ -317,6 +326,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 @@ -324,32 +336,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 @@ -357,21 +371,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 @@ -379,219 +393,225 @@ 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 {}." -msgstr "" +msgstr "No hay tipos de operación configurados en el menú {}." #. 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 @@ -600,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 @@ -608,70 +630,73 @@ 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 #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__options msgid "Options" -msgstr "" +msgstr "Opciones" #. 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 @@ -679,170 +704,174 @@ 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.model.fields,field_description:shopfloor.field_shopfloor_menu__profile_id #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view msgid "Profile" -msgstr "" +msgstr "Perfil" #. module: shopfloor #: model:ir.actions.act_window,name:shopfloor.action_shopfloor_profile #: 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 #: model:ir.model.fields,help:shopfloor.field_stock_quant_package__shopfloor_weight msgid "Real pack weight or the estimated one." -msgstr "" +msgstr "Peso real del paquete o estimado." #. module: shopfloor #: code:addons/shopfloor/services/scan_anything.py:0 @@ -851,92 +880,95 @@ 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 "Same package {} is already assigned." -msgstr "" +msgstr "El mismo paquete {} ya está asignado." #. 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 #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view 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 @@ -947,35 +979,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 @@ -984,22 +1020,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 @@ -1007,73 +1045,73 @@ 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:ir.model.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight msgid "Shopfloor weight (kg)" -msgstr "" +msgstr "Peso del taller (kg)" #. 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 @@ -1082,46 +1120,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 @@ -1131,33 +1173,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 @@ -1165,6 +1209,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 @@ -1173,36 +1219,38 @@ msgid "" "The backorder %s has been created." msgstr "" +"Se ha creado el pedido pendiente %s." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "The content of {} cannot be transferred with this scenario." -msgstr "" +msgstr "El contenido de {} no se puede transferir con este escenario." #. 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 cannot be transferred with this scenario." -msgstr "" +msgstr "El paquete %s no puede ser transferido con este escenario." #. 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 @@ -1213,59 +1261,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 @@ -1273,30 +1326,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 @@ -1305,137 +1359,138 @@ 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_id msgid "Visible on this profile only" -msgstr "" +msgstr "Visible en este perfil solo" #. 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 @@ -1444,9 +1499,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 d8f6cb81eca6df0857dcb70170147ec4cd27ae24 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 29 Jan 2021 16:08:39 +0100 Subject: [PATCH 516/710] shopfloor: use db logging from rest_log --- shopfloor/__manifest__.py | 4 +- shopfloor/data/ir_config_parameter_data.xml | 7 - shopfloor/data/ir_cron_data.xml | 15 -- shopfloor/models/shopfloor_log.py | 160 +++----------- shopfloor/security/ir.model.access.csv | 2 +- shopfloor/services/service.py | 142 +----------- shopfloor/tests/__init__.py | 1 - shopfloor/tests/test_db_logging.py | 227 -------------------- shopfloor/views/menus.xml | 7 - shopfloor/views/shopfloor_log_views.xml | 167 -------------- 10 files changed, 39 insertions(+), 693 deletions(-) delete mode 100644 shopfloor/data/ir_config_parameter_data.xml delete mode 100644 shopfloor/data/ir_cron_data.xml delete mode 100644 shopfloor/tests/test_db_logging.py delete mode 100644 shopfloor/views/shopfloor_log_views.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 51f122c58ec..b774e30e28e 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -19,6 +19,7 @@ "stock_picking_batch", "base_jsonify", "base_rest", + "rest_log", "base_sparse_field", "auth_api_key", # OCA / stock-logistics-warehouse @@ -43,15 +44,12 @@ "product_packaging_type", ], "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 deleted file mode 100644 index 2127ea5cbe6..00000000000 --- a/shopfloor/data/ir_config_parameter_data.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - shopfloor.log.retention.days - 30 - - diff --git a/shopfloor/data/ir_cron_data.xml b/shopfloor/data/ir_cron_data.xml deleted file mode 100644 index 7c5809584bc..00000000000 --- a/shopfloor/data/ir_cron_data.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - Auto-vacuum Shopfloor Logs - - - - 1 - days - -1 - - code - model.autovacuum() - - diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py index 9e94415f013..4f57a594bc7 100644 --- a/shopfloor/models/shopfloor_log.py +++ b/shopfloor/models/shopfloor_log.py @@ -1,131 +1,41 @@ # 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 - -from odoo import api, fields, models, tools - -_logger = logging.getLogger(__name__) +from odoo import models + +# TODO: drop this model on next release. +# The feature has been moved to `rest_log`. +# +# It has been tried several times +# to drop the class altogether w/ its related records in this module +# but the ORM is uncapable do delete many records directly relate to the model +# (selection fields values, server actions for crons, etc.) +# and the upgrade will be always broken +# because the model is not in the registry anymore. +# +# Example of the error: +# [2021-02-01 08:44:52,103 1 INFO odoodb ]odoo.addons.base.models.ir_model: +# Deleting 2617@ir.model.fields.selection +# (shopfloor.selection__shopfloor_log__severity__severe) +# 2021-02-01 08:44:52,107 1 WARNING odoodb odoo.modules.loading: +# Transient module states were reset +# 2021-02-01 08:44:52,110 1 ERROR odoodb odoo.modules.registry: +# Failed to load registry +# Traceback (most recent call last): +# File "/odoo/src/odoo/modules/registry.py", line 86, in new +# odoo.modules.load_modules(registry._db, force_demo, status, update_module) +# File "/odoo/src/odoo/modules/loading.py", line 472, in load_modules +# env['ir.model.data']._process_end(processed_modules) +# File "/odoo/src/odoo/addons/base/models/ir_model.py", line 2012, in _process_end +# record.unlink() +# File "/odoo/src/odoo/addons/base/models/ir_model.py", line 1206, in unlink +# not self.env[selection.field_id.model]._abstract: +# File "/odoo/src/odoo/api.py", line 463, in __getitem__ +# return self.registry[model_name]._browse(self, (), ()) +# File "/odoo/src/odoo/modules/registry.py", line 177, in __getitem__ +# return self.models[model_name] +# KeyError: 'shopfloor.log' class ShopfloorLog(models.Model): _name = "shopfloor.log" - _description = "Shopfloor Logging" - _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) - 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) - 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.info( - "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 - 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 + _description = "Legacy model for tracking REST calls: replacedy by rest.log" diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv index bdf728b8e5e..a3454e230ea 100644 --- a/shopfloor/security/ir.model.access.csv +++ b/shopfloor/security/ir.model.access.csv @@ -3,4 +3,4 @@ "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","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 +"access_shopfloor_log","access_shopfloor_log","model_shopfloor_log","base.group_user",1,0,0,0 diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 71911488cde..cca0143f446 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -1,13 +1,9 @@ # 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.exceptions import BadRequest -from werkzeug.urls import url_encode, url_join -from odoo import _, exceptions, registry +from odoo import _, exceptions from odoo.exceptions import MissingError from odoo.http import request from odoo.osv import expression @@ -24,27 +20,6 @@ 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 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""" @@ -53,124 +28,11 @@ 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, *args, params=None): self._validate_headers_update_work_context(request, method_name) - if not self._db_logging_active(): - 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 ( - 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, *args, params=None): - try: - result = super().dispatch(method_name, *args, params=params) - except exceptions.UserError as orig_exception: - self._dispatch_exception( - ShopfloorServiceUserErrorException, - orig_exception, - *args, - params=params, - ) - except exceptions.ValidationError as orig_exception: - self._dispatch_exception( - ShopfloorServiceValidationErrorException, - orig_exception, - *args, - params=params, - ) - except Exception as orig_exception: - self._dispatch_exception( - ShopfloorServiceDispatchException, orig_exception, *args, params=params, - ) - 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, *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, - *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 - 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 = { - "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") - - 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 args: - params = dict(params or {}, args=args) - - 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, - "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, - "state": "success" if result else "failed", - } - - 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) + return super().dispatch(method_name, *args, params=params) def _get(self, _id): domain = expression.normalize_domain(self._get_base_search_domain()) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 0a5575d4681..7749abc9871 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -68,4 +68,3 @@ 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 deleted file mode 100644 index 3efbbe666c1..00000000000 --- a/shopfloor/tests/test_db_logging.py +++ /dev/null @@ -1,227 +0,0 @@ -# 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 -import json - -from odoo import exceptions -from odoo.tools import mute_logger - -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.picking_type = cls.menu.picking_type_ids - cls.wh = cls.picking_type.warehouse_id - 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=params, **kw - ) - expected = { - "request_url": httprequest["url"], - "request_method": httprequest["method"], - "state": "success", - "error": False, - "exception_name": False, - "severity": False, - } - self.assertRecordValues(entry, [expected]) - expected_json = { - "result": {"data": "worked!"}, - "params": dict(params, args=[_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" - 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": "{}", - "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": "{}", - "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": "{}", - "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") - - @mute_logger("odoo.addons.shopfloor.models.shopfloor_log") - 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/menus.xml b/shopfloor/views/menus.xml index f1a619ed6b4..29876a3a776 100644 --- a/shopfloor/views/menus.xml +++ b/shopfloor/views/menus.xml @@ -18,11 +18,4 @@ parent="menu_shopfloor_settings" sequence="20" /> - diff --git a/shopfloor/views/shopfloor_log_views.xml b/shopfloor/views/shopfloor_log_views.xml deleted file mode 100644 index 603a185c3b7..00000000000 --- a/shopfloor/views/shopfloor_log_views.xml +++ /dev/null @@ -1,167 +0,0 @@ - - - - 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 1bb242ba70d6a8e96d3286eaae0a8d1f297d0f82 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 11 Feb 2021 10:58:52 +0100 Subject: [PATCH 517/710] shopfloor: bump 13.0.3.0.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index b774e30e28e..19dc90a1d9b 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.2.5.0", + "version": "13.0.3.0.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From d4757ffe6358044dcd2e0b092a30a49f1dd74a04 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 11 Feb 2021 10:44:55 +0000 Subject: [PATCH 518/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 186 +---------------------------------- 1 file changed, 5 insertions(+), 181 deletions(-) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 6f2440c3c44..04df978ba43 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -77,13 +77,6 @@ msgstr "" 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 @@ -137,12 +130,6 @@ msgstr "" 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 @@ -155,11 +142,6 @@ msgstr "" 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 @@ -206,11 +188,6 @@ msgstr "" 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 @@ -225,40 +202,6 @@ msgstr "" 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 "" @@ -267,40 +210,16 @@ msgid "" "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 #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_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 @@ -379,6 +298,11 @@ msgid "" "Last operation of transfer {}. Next operation ({}) is ready to proceed." msgstr "" +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_log +msgid "Legacy model for tracking REST calls: replacedy by rest.log" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -415,11 +339,6 @@ msgstr "" 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 @@ -732,21 +651,6 @@ msgstr "" 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 @@ -863,17 +767,6 @@ msgstr "" 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 @@ -886,12 +779,6 @@ msgstr "" 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" @@ -984,25 +871,8 @@ msgid "" "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" @@ -1013,17 +883,6 @@ msgstr "" 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" @@ -1092,16 +951,6 @@ msgstr "" 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" @@ -1117,11 +966,6 @@ msgstr "" 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 @@ -1317,11 +1161,6 @@ msgstr "" 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 @@ -1368,26 +1207,11 @@ msgstr "" 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_id msgid "Visible on this profile only" 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 From 68cb2df695276bb71a6c9b8354b7dc7e981154b4 Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Thu, 11 Feb 2021 10:45:16 +0000 Subject: [PATCH 519/710] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/ --- shopfloor/i18n/es_AR.po | 332 +++++++++++++++------------------------- 1 file changed, 126 insertions(+), 206 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 9889e5efda4..55094041bfb 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -80,13 +80,6 @@ msgstr "Archivado" msgid "Are you sure?" 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 "Eliminación Automática de Registros del Taller" - #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -140,12 +133,6 @@ msgstr "No se puede cambiar al lote {} ya que está completamente recogido." msgid "Checkout" 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 "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 @@ -158,11 +145,6 @@ msgstr "Grupo de Picking" msgid "Confirm location change from %s to %s?" msgstr "¿Confirma cambiar ubicación desde %s hacia %s?" -#. module: shopfloor -#: model:ir.model,name:shopfloor.model_res_partner -msgid "Contact" -msgstr "Contacto" - #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -192,8 +174,8 @@ msgstr "Creado por" #: code:addons/shopfloor/models/stock_move.py:0 #, python-format msgid "" -"Created from backorder %s." +"Created from backorder " +"%s." msgstr "" "Creado desde pedido pendiente %s." @@ -211,11 +193,6 @@ msgstr "Creado el" msgid "Creation of moves is not allowed for menu {}." 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 "Fecha" - #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery #: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo @@ -230,40 +207,6 @@ msgstr "Entrega" msgid "Display Name" 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 "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 "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 "Excepción" - -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__exception_message -msgid "Exception Message" -msgstr "Mensaje de Excepción" - -#. module: shopfloor -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view -msgid "Exception message" -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 "Fallido" - #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check msgid "" @@ -275,42 +218,16 @@ msgstr "" "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 -#: 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 "" -"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 "Desde" #. module: shopfloor -#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__functional -msgid "Functional" -msgstr "Funcional" - -#. module: shopfloor -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view -msgid "Functional errors" -msgstr "Errores funcionales" - -#. module: shopfloor -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view #: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view msgid "Group By" msgstr "Agrupar por" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__headers -msgid "Headers" -msgstr "Cabeceras" - #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__id @@ -322,8 +239,8 @@ msgstr "ID" #. 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 " +"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 "" "Si marca esta casilla, la transferencia se reserva solo si la ubicación " @@ -390,12 +307,16 @@ msgstr "Última Actualización el" #. module: shopfloor #: code:addons/shopfloor/actions/completion_info.py:0 #, python-format -msgid "" -"Last operation of transfer {}. Next operation ({}) is ready to proceed." +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 +#: model:ir.model,name:shopfloor.model_shopfloor_log +msgid "Legacy model for tracking REST calls: replacedy by rest.log" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -432,11 +353,6 @@ msgstr "La Ubicación no está permitida aquí." msgid "Location {} empty" msgstr "Ubicación {} vacía" -#. module: shopfloor -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view -msgid "Logs generated today" -msgstr "Registros generados hoy" - #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -608,7 +524,6 @@ 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 "No es un paquete de destino válido" @@ -617,8 +532,8 @@ msgstr "No es un paquete de destino válido" #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "" -"Not all lines have been processed with full quantity. Do you confirm partial" -" operation?" +"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?" @@ -760,21 +675,6 @@ msgstr "Paquetes" msgid "Packaging changed on package {}" 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 "Información del empaquetado" - -#. module: shopfloor -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_form_view -msgid "Parameters" -msgstr "Parámetros" - -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__params -msgid "Params" -msgstr "Parámetros" - #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 #, python-format @@ -896,17 +796,6 @@ 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 "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 "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 @@ -919,12 +808,6 @@ msgstr "Movimiento de Línea Reservado" msgid "Restart the operation, someone has canceled it." 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 "Resultado" - #. module: shopfloor #: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree msgid "SF Priority" @@ -954,7 +837,6 @@ 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 "Escanear el paquete" @@ -1017,31 +899,13 @@ msgstr "Se han encontrado varios productos en %s, escanee un producto." #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "" -"Several transfers found, please scan a package or select a transfer " -"manually." +"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 "Severo" - -#. module: shopfloor -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view -msgid "Severe errors" -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 "Severidad" - #. 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" @@ -1052,17 +916,6 @@ msgstr "Taller" msgid "Shopfloor Checkout Done" msgstr "Checkout del Taller Hecho" -#. module: shopfloor -#: model:ir.model,name:shopfloor.model_shopfloor_log -msgid "Shopfloor Logging" -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 "Registros del Taller" - #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids msgid "Shopfloor Menus" @@ -1116,9 +969,9 @@ msgstr "Transferencia de un solo Palet" #. 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." +"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 "" "Algunos escenarios pueden crear movimientos cuando se escanea un producto o " "paquete cuando no existe ningún movimiento. Cualquier movimiento nuevo se " @@ -1135,16 +988,6 @@ msgstr "Recurso del Movimiento de Línea" msgid "Source Package" msgstr "Recurso del paquete" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__state -msgid "State" -msgstr "Estado" - -#. module: shopfloor -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view -msgid "Status" -msgstr "Estado" - #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_move msgid "Stock Move" @@ -1160,11 +1003,6 @@ msgstr "Nivel de Paquete de Existencias" msgid "Stock Picking" msgstr "Inventario de la Entrega" -#. module: shopfloor -#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__state__success -msgid "Success" -msgstr "Satisfactorio" - #. 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 @@ -1216,11 +1054,11 @@ msgstr "" #: code:addons/shopfloor/models/stock_move_line.py:0 #, python-format msgid "" -"The backorder %s has been created." +"The backorder %s has been created." msgstr "" -"Se ha creado el pedido pendiente %s." +"Se ha creado el pedido pendiente %s." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -1257,8 +1095,8 @@ msgstr "El paquete %s no existe" 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 " +"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 "" "La entrega realizada en los escenarios del Taller respetará este orden. La " @@ -1356,8 +1194,7 @@ msgstr "Este producto ya no existe." #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "" -"This product is part of a package with other products, please scan a " -"package." +"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." @@ -1373,11 +1210,6 @@ msgstr "Este producto es parte de varios paquetes, escanee un paquete." msgid "This transfer does not exist or is not available anymore." 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 "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 @@ -1424,26 +1256,11 @@ msgstr "Error irrecuperable, por favor reinicie." msgid "Unreserve Other Moves Is Possible" 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 "Usuario" - #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_id msgid "Visible on this profile only" msgstr "Visible en este perfil solo" -#. module: shopfloor -#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_log__severity__warning -msgid "Warning" -msgstr "Advertencia" - -#. module: shopfloor -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_log_search_view -msgid "Warning errors" -msgstr "Errores de advertencia" - #. module: shopfloor #: code:addons/shopfloor/services/cluster_picking.py:0 #, python-format @@ -1507,3 +1324,106 @@ msgstr "" #, python-format msgid "{} {} put in {}" msgstr "{} {} poner en {}" + +#~ msgid "Auto-vacuum Shopfloor Logs" +#~ msgstr "Eliminación Automática de Registros del Taller" + +#~ msgid "Checkout Packing Information" +#~ msgstr "Información del Paquete del Checkout" + +#~ msgid "Contact" +#~ msgstr "Contacto" + +#~ msgid "Date" +#~ msgstr "Fecha" + +#~ msgid "Display customer packing info" +#~ msgstr "Mostrar información del empaquetado de cliente" + +#~ msgid "Error" +#~ msgstr "Error" + +#~ msgid "Exception" +#~ msgstr "Excepción" + +#~ msgid "Exception Message" +#~ msgstr "Mensaje de Excepción" + +#~ msgid "Exception message" +#~ msgstr "Mensaje de Excepción" + +#~ msgid "Failed" +#~ msgstr "Fallido" + +#~ 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." + +#~ msgid "Functional" +#~ msgstr "Funcional" + +#~ msgid "Functional errors" +#~ msgstr "Errores funcionales" + +#~ msgid "Headers" +#~ msgstr "Cabeceras" + +#~ msgid "Logs generated today" +#~ msgstr "Registros generados hoy" + +#~ msgid "Packing information" +#~ msgstr "Información del empaquetado" + +#~ msgid "Parameters" +#~ msgstr "Parámetros" + +#~ msgid "Params" +#~ msgstr "Parámetros" + +#~ msgid "Request Method" +#~ msgstr "Método del Request" + +#~ msgid "Request URL" +#~ msgstr "URL del Request" + +#~ msgid "Result" +#~ msgstr "Resultado" + +#~ msgid "Severe" +#~ msgstr "Severo" + +#~ msgid "Severe errors" +#~ msgstr "Errores severos" + +#~ msgid "Severity" +#~ msgstr "Severidad" + +#~ msgid "Shopfloor Logging" +#~ msgstr "Registro del Taller" + +#~ msgid "Shopfloor Logs" +#~ msgstr "Registros del Taller" + +#~ msgid "State" +#~ msgstr "Estado" + +#~ msgid "Status" +#~ msgstr "Estado" + +#~ msgid "Success" +#~ msgstr "Satisfactorio" + +#~ msgid "Today" +#~ msgstr "Hoy" + +#~ msgid "User" +#~ msgstr "Usuario" + +#~ msgid "Warning" +#~ msgstr "Advertencia" + +#~ msgid "Warning errors" +#~ msgstr "Errores de advertencia" From 7ad13cf1ea931def8284138a9357cbe1faa6ffc0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 11 Feb 2021 16:29:59 +0100 Subject: [PATCH 520/710] shopfloor: include carrier in picking data --- shopfloor/actions/data.py | 1 + shopfloor/actions/data_detail.py | 1 - shopfloor/services/schema.py | 11 ++-------- shopfloor/services/schema_detail.py | 1 - shopfloor/tests/test_actions_data.py | 6 +++++- shopfloor/tests/test_actions_data_detail.py | 4 ++-- shopfloor/tests/test_checkout_list_package.py | 20 +++++++++---------- 7 files changed, 20 insertions(+), 24 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index e36714daf20..8d59f2d27b0 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -85,6 +85,7 @@ def _picking_parser(self): "origin", "note", ("partner_id:partner", self._partner_parser), + ("carrier_id:carrier", self._simple_record_parser()), "move_line_count", "total_weight:weight", "scheduled_date", diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py index d25127aab2e..7ecb450832e 100644 --- a/shopfloor/actions/data_detail.py +++ b/shopfloor/actions/data_detail.py @@ -46,7 +46,6 @@ def _picking_detail_parser(self): ("priority", self._select_value_to_label), "scheduled_date", ("picking_type_id:operation_type", ["id", "name"]), - ("carrier_id:carrier", ["id", "name"]), ( "move_line_ids:move_lines", lambda record, fname: self.move_lines(record[fname]), diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index c86e11360ca..7dda0d0243a 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -62,15 +62,8 @@ def picking(self): "note": {"type": "string", "nullable": True, "required": False}, "move_line_count": {"type": "integer", "nullable": True, "required": True}, "weight": {"required": True, "nullable": True, "type": "float"}, - "partner": { - "type": "dict", - "nullable": True, - "required": True, - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, + "partner": self._schema_dict_of(self._simple_record()), + "carrier": self._schema_dict_of(self._simple_record(), required=False), "scheduled_date": {"type": "string", "nullable": False, "required": True}, } diff --git a/shopfloor/services/schema_detail.py b/shopfloor/services/schema_detail.py index cb4e10b0385..e87a2179fb4 100644 --- a/shopfloor/services/schema_detail.py +++ b/shopfloor/services/schema_detail.py @@ -39,7 +39,6 @@ def picking_detail(self): }, "priority": {"type": "string", "nullable": True, "required": False}, "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/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 55bec2d1a74..3b51b83ca7f 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -238,7 +238,10 @@ def test_data_package_level(self): self.assertDictEqual(data, expected) def test_data_picking(self): - self.picking.write({"origin": "created by test", "note": "read me"}) + carrier = self.picking.carrier_id.search([], limit=1) + self.picking.write( + {"origin": "created by test", "note": "read me", "carrier_id": carrier.id} + ) data = self.data.picking(self.picking) self.assert_schema(self.schema.picking(), data) expected = { @@ -249,6 +252,7 @@ def test_data_picking(self): "origin": "created by test", "weight": 110.0, "partner": {"id": self.customer.id, "name": self.customer.name}, + "carrier": {"id": carrier.id, "name": carrier.name}, } self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-08-03") self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index d198d9a6bc5..53bb743d1be 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -158,7 +158,7 @@ def test_data_package(self): def test_data_picking(self): picking = self.picking - carrier = picking.carrier_id.search([])[0] + carrier = picking.carrier_id.search([], limit=1) picking.write( { "origin": "created by test", @@ -178,12 +178,12 @@ def test_data_picking(self): "origin": "created by test", "weight": 110.0, "partner": {"id": self.customer.id, "name": self.customer.name}, + "carrier": {"id": picking.carrier_id.id, "name": picking.carrier_id.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}, "move_lines": self.data_detail.move_lines(picking.move_line_ids), "picking_type_code": "outgoing", } diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index ec26b756db9..956f7eabed2 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -8,20 +8,20 @@ class SelectDestPackageMixin: def _assert_response_select_dest_package( self, response, picking, selected_lines, packages, message=None ): + picking_data = self.data.picking(picking) + picking_data.update( + { + "note": None, + "origin": None, + "weight": 110.0, + "move_line_count": len(picking.move_line_ids), + } + ) self.assert_response( response, next_state="select_dest_package", data={ - "picking": { - "id": picking.id, - "name": picking.name, - "note": None, - "origin": None, - "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", - }, + "picking": picking_data, "packages": [ self._package_data( package.with_context(picking_id=picking.id), picking From 7a38f9adb6e6dd24d363f1da8117c17c6b0bab8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 5 Feb 2021 16:12:54 +0100 Subject: [PATCH 521/710] shopfloor: handle better lines priority Before: when postponing lines to process, the priority was set to a constant value 9999. But if all remaining lines to process have been postponed, their priority was all set to 9999 (= no order) and it is impossible to know what was the first lines which have been postponed and that we should care about. Also, the `shopfloor_postponed` boolean field was returning `True` if the current priority was equal to 9999. After: Now to postpone a line we have to call the `shopfloor_postpone()` method which will increment the priority of the line based on the max priority used among all other lines of the current scope. As a result, lines will always been ordered as expected, but as a side-effect the `shopfloor_postponed` boolean field will always return `True` as soon as the line has been postponed, but this is probably not an issue in the lifecycle of the processed line. --- shopfloor/models/priority_postpone_mixin.py | 37 +++++++++---------- shopfloor/services/cluster_picking.py | 2 +- .../services/location_content_transfer.py | 16 ++++++-- shopfloor/tests/test_cluster_picking_skip.py | 12 ++++++ .../test_location_content_transfer_single.py | 4 ++ 5 files changed, 47 insertions(+), 24 deletions(-) diff --git a/shopfloor/models/priority_postpone_mixin.py b/shopfloor/models/priority_postpone_mixin.py index 80832f6d7e8..d501a117fdb 100644 --- a/shopfloor/models/priority_postpone_mixin.py +++ b/shopfloor/models/priority_postpone_mixin.py @@ -1,15 +1,12 @@ # 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 +from odoo import 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( @@ -17,24 +14,26 @@ class PriorityPostponeMixin(models.AbstractModel): copy=False, help="Technical field. Overrides operation priority in barcode scenario.", ) - shopfloor_postponed = fields.Boolean( - compute="_compute_shopfloor_postponed", - inverse="_inverse_shopfloor_postponed", + copy=False, 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 _get_max_shopfloor_priority(self, records): + self.ensure_one() + return max(rec.shopfloor_priority for rec in records) + + def shopfloor_postpone(self, *recordsets): + """Postpone the record and update its priority based on other records. - 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 + The method accepts several recordsets as parameter (to be able to get + the current max priority from different types of records). + """ + self.ensure_one() + # Set the max priority from sibling records + 1 + max_priority = max( + self._get_max_shopfloor_priority(records) for records in recordsets + ) + self.shopfloor_priority = max_priority + 1 + self.shopfloor_postponed = True diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index c467a75bd41..bd36ba886ce 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -749,7 +749,7 @@ def skip_line(self, picking_batch_id, move_line_id): batch, message=self.msg_store.operation_not_found() ) # flag as postponed - move_line.shopfloor_postponed = True + move_line.shopfloor_postpone(self._lines_to_pick(batch)) return self._pick_after_skip_line(move_line) def _pick_after_skip_line(self, move_line): diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 66b4237f26a..2b9912fe84d 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -767,9 +767,13 @@ def postpone_package(self, location_id, package_level_id): package_level = self.env["stock.package_level"].browse(package_level_id) if not location.exists(): return self._response_for_start(message=self.msg_store.record_not_found()) - if package_level.exists(): - package_level.shopfloor_postponed = True move_lines = self._find_transfer_move_lines(location) + if package_level.exists(): + pickings = move_lines.mapped("picking_id") + sorter = self.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + package_levels = sorter.package_levels() + package_level.shopfloor_postpone(move_lines, package_levels) return self._response_for_start_single(move_lines.mapped("picking_id")) def postpone_line(self, location_id, move_line_id): @@ -782,9 +786,13 @@ def postpone_line(self, location_id, move_line_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 move_line.exists(): - move_line.shopfloor_postponed = True move_lines = self._find_transfer_move_lines(location) + if move_line.exists(): + pickings = move_lines.mapped("picking_id") + sorter = self.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + package_levels = sorter.package_levels() + move_line.shopfloor_postpone(move_lines, package_levels) return self._response_for_start_single(move_lines.mapped("picking_id")) def stock_out_package(self, location_id, package_level_id): diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py index 03feec18fd3..c486509eac2 100644 --- a/shopfloor/tests/test_cluster_picking_skip.py +++ b/shopfloor/tests/test_cluster_picking_skip.py @@ -59,17 +59,29 @@ def test_skip_line(self): ) # skip line from loc 1 + previous_priority = loc1_lines[0].shopfloor_priority self._skip_line(loc1_lines[0], loc1_lines[1]) + self.assertEqual(loc1_lines[0].shopfloor_priority, previous_priority + 1) + loc1_lines.invalidate_cache(["shopfloor_postponed"]) 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]) + # Priority is now the current max + 1 + self.assertEqual( + loc1_lines[1].shopfloor_priority, loc1_lines[0].shopfloor_priority + 1 + ) + loc1_lines.invalidate_cache(["shopfloor_postponed"]) self.assertTrue(loc1_lines[1].shopfloor_postponed) # 3rd line, next is 4th self.assertFalse(loc2_lines[0].shopfloor_postponed) self._skip_line(loc2_lines[0], loc2_lines[1]) + self.assertEqual( + loc2_lines[0].shopfloor_priority, loc1_lines[1].shopfloor_priority + 1 + ) + loc1_lines.invalidate_cache(["shopfloor_postponed"]) self.assertTrue(loc2_lines[0].shopfloor_postponed) diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index ae416df8866..831f7f97d57 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -327,6 +327,7 @@ def test_postpone_package_wrong_parameters(self): def test_postpone_package_ok(self): package_level = self.picking1.move_line_ids.package_level_id + previous_priority = package_level.shopfloor_priority self.assertFalse(package_level.shopfloor_postponed) response = self.service.dispatch( "postpone_package", @@ -336,6 +337,7 @@ def test_postpone_package_ok(self): }, ) self.assertTrue(package_level.shopfloor_postponed) + self.assertEqual(package_level.shopfloor_priority, previous_priority + 1) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( response, @@ -387,12 +389,14 @@ def test_postpone_line_wrong_parameters(self): def test_postpone_line_ok(self): move_line = self.picking2.move_line_ids[0] + previous_priority = move_line.shopfloor_priority self.assertFalse(move_line.shopfloor_postponed) response = self.service.dispatch( "postpone_line", params={"location_id": self.content_loc.id, "move_line_id": move_line.id}, ) self.assertTrue(move_line.shopfloor_postponed) + self.assertEqual(move_line.shopfloor_priority, previous_priority + 1) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( response, From 9a431c2d86a8e7479740a611ac5dc512aa44c9ba Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 12 Feb 2021 17:15:14 +0000 Subject: [PATCH 522/710] shopfloor 13.0.3.0.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 19dc90a1d9b..868e05eeb32 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.3.0.0", + "version": "13.0.3.0.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 0e2352172f469d2cbfea5b48765c77fcd75d2cc7 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 11 Feb 2021 16:32:40 +0100 Subject: [PATCH 523/710] shopfloor: checkout validate carrier for delivery packaging --- shopfloor/__manifest__.py | 3 +- shopfloor/actions/message.py | 8 ++ shopfloor/services/checkout.py | 14 ++++ .../test_checkout_scan_package_action.py | 76 ++++++++++++++++++- 4 files changed, 99 insertions(+), 2 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 868e05eeb32..15b51297180 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -37,7 +37,8 @@ # 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. + # TODO: used for picking.carrier_id detail info + # and to validate packaging/carrier in checkout scenario # This must be an optional dep "delivery", # OCA / product-attribute diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index a4acfc3ed9e..053704db186 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -506,3 +506,11 @@ def package_open(self): "message_type": "info", "body": _("Package has been opened. You can move partial quantities."), } + + def packaging_invalid_for_carrier(self, packaging, carrier): + return { + "message_type": "error", + "body": _("Packaging {} does not match carrier {}.").format( + packaging.name, carrier.name + ), + } diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 654e2963198..b0163d32171 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -736,8 +736,19 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): if package: return self._put_lines_in_package(picking, selected_lines, package) + # Scan delivery packaging packaging = search.generic_packaging_from_scan(barcode) if packaging: + carrier = picking.carrier_id + # Validate against carrier + if carrier and not self._packaging_good_for_carrier(packaging, carrier): + return self._response_for_select_package( + picking, + selected_lines, + message=self.msg_store.packaging_invalid_for_carrier( + packaging, carrier + ), + ) return self._create_and_assign_new_packaging( picking, selected_lines, packaging ) @@ -746,6 +757,9 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): picking, selected_lines, message=self.msg_store.barcode_not_found() ) + def _packaging_good_for_carrier(self, packaging, carrier): + return packaging.package_carrier_type in ("none", carrier.delivery_type) + def new_package(self, picking_id, selected_line_ids): """Add all selected lines in a new package diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index 948e3358405..3592649589d 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -2,6 +2,8 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from itertools import product +import mock + from .test_checkout_base import CheckoutCommonCase from .test_checkout_select_package_base import CheckoutSelectPackageMixin @@ -351,7 +353,6 @@ def test_scan_package_action_scan_packaging_ok(self): ) 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={ @@ -360,6 +361,79 @@ def test_scan_package_action_scan_packaging_ok(self): }, ) + def test_scan_package_action_scan_packaging_bad_carrier(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.carrier_id = picking.carrier_id.search([], limit=1) + pack1_moves = picking.move_lines + # put in 2 packs, for this test, we'll work on pack1 + self._fill_stock_for_moves(pack1_moves, in_package=True) + picking.action_assign() + selected_lines = pack1_moves.move_line_ids + selected_lines.qty_done = selected_lines.product_uom_qty + + packaging = ( + self.env["product.packaging"] + .sudo() + .create( + { + "name": "DeliverX", + "barcode": "XXX", + "height": 12, + "width": 13, + "lngth": 14, + } + ) + ) + # Delivery type and package_carrier_type values + # depend on specific implementations that we don't have as dependency. + # What is important here is to simulate their value when mismatching. + mock1 = mock.patch.object( + type(packaging), "package_carrier_type", new_callable=mock.PropertyMock, + ) + mock2 = mock.patch.object( + type(picking.carrier_id), "delivery_type", new_callable=mock.PropertyMock, + ) + with mock1 as mocked_package_carrier_type, mock2 as mocked_delivery_type: + # Not matching at all -> bad + mocked_package_carrier_type.return_value = "DHL" + mocked_delivery_type.return_value = "UPS" + 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, + }, + ) + self._assert_selected_response( + response, + selected_lines, + message=self.msg_store.packaging_invalid_for_carrier( + packaging, picking.carrier_id + ), + ) + # No carrier type set on the packaging -> good + mocked_package_carrier_type.return_value = "none" + 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, + }, + ) + self.assertEqual( + response["message"], + { + "message_type": "success", + "body": "Product(s) packed in {}".format( + selected_lines.result_package_id.name + ), + }, + ) + def test_scan_package_action_scan_not_found(self): picking = self._create_picking(lines=[(self.product_a, 10)]) move = picking.move_lines From 7a17e03407f7599a24cb3d313eac83e7b0a05c4d Mon Sep 17 00:00:00 2001 From: oca-travis Date: Mon, 15 Feb 2021 13:06:08 +0000 Subject: [PATCH 524/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 04df978ba43..073ed7806a7 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -651,6 +651,12 @@ msgstr "" msgid "Packaging changed on package {}" msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging {} does not match carrier {}." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 #, python-format From 7859564a647ca70d0c89d3143b8400f116858a2e Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 15 Feb 2021 13:19:28 +0000 Subject: [PATCH 525/710] shopfloor 13.0.3.1.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 15b51297180..652a259b3d2 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.3.0.1", + "version": "13.0.3.1.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 08f387df0228b9ab66dfafb0ae68d51fc0db1c06 Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Mon, 15 Feb 2021 13:19:52 +0000 Subject: [PATCH 526/710] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/ --- shopfloor/i18n/es_AR.po | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 55094041bfb..180d78890c0 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -675,6 +675,12 @@ msgstr "Paquetes" msgid "Packaging changed on package {}" msgstr "El Empaquetado cambió en el paquete {}" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging {} does not match carrier {}." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 #, python-format From 1ab0b6d9050eda87a3cd336a1e7e74b040f30f38 Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Tue, 16 Feb 2021 19:10:13 +0000 Subject: [PATCH 527/710] Translated using Weblate (Spanish (Argentina)) Currently translated at 100.0% (210 of 210 strings) Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/es_AR/ --- shopfloor/i18n/es_AR.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 180d78890c0..0e43dfe5453 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 13.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2021-02-06 22:45+0000\n" +"PO-Revision-Date: 2021-02-16 21:45+0000\n" "Last-Translator: Ignacio Buioli \n" "Language-Team: none\n" "Language: es_AR\n" @@ -315,7 +315,7 @@ msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_shopfloor_log msgid "Legacy model for tracking REST calls: replacedy by rest.log" -msgstr "" +msgstr "Modelo heredado para rastrear llamadas REST: reemplazado por rest.log" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 @@ -679,7 +679,7 @@ msgstr "El Empaquetado cambió en el paquete {}" #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Packaging {} does not match carrier {}." -msgstr "" +msgstr "El Empaquetado {} no coincide con el transportista {}." #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 From 68391e67f68972e6ec37187416a015035cb867c5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 2 Feb 2021 10:11:19 +0100 Subject: [PATCH 528/710] Add shopfloor_base (split from shopfloor) --- shopfloor/security/groups.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 shopfloor/security/groups.xml diff --git a/shopfloor/security/groups.xml b/shopfloor/security/groups.xml new file mode 100644 index 00000000000..bc177166d02 --- /dev/null +++ b/shopfloor/security/groups.xml @@ -0,0 +1,17 @@ + + + + + + + + + + From de189fb333110a742def588784bbedc6d1d8e55c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 2 Feb 2021 16:06:44 +0100 Subject: [PATCH 529/710] shopfloor: refactor on top of shopfloor_base --- shopfloor/__init__.py | 1 - shopfloor/__manifest__.py | 4 +- shopfloor/actions/__init__.py | 21 +- shopfloor/actions/base_action.py | 18 -- shopfloor/actions/change_package_lot.py | 2 +- shopfloor/actions/data.py | 52 +--- shopfloor/actions/data_detail.py | 10 +- shopfloor/actions/message.py | 13 +- shopfloor/actions/search.py | 2 + shopfloor/controllers/__init__.py | 1 - shopfloor/controllers/main.py | 11 - shopfloor/data/shopfloor_scenario_data.xml | 26 ++ shopfloor/demo/auth_api_key_demo.xml | 7 - shopfloor/demo/shopfloor_menu_demo.xml | 12 +- shopfloor/demo/shopfloor_profile_demo.xml | 8 - shopfloor/models/__init__.py | 1 - shopfloor/models/shopfloor_menu.py | 55 +--- shopfloor/models/shopfloor_profile.py | 17 -- shopfloor/security/ir.model.access.csv | 4 - shopfloor/services/__init__.py | 6 - shopfloor/services/app.py | 61 ----- shopfloor/services/checkout.py | 10 +- shopfloor/services/cluster_picking.py | 22 +- shopfloor/services/delivery.py | 2 +- shopfloor/services/forms/__init__.py | 1 - .../services/location_content_transfer.py | 36 +-- shopfloor/services/menu.py | 91 +------ shopfloor/services/profile.py | 80 ------ shopfloor/services/service.py | 94 +------ shopfloor/services/single_pack_transfer.py | 10 +- shopfloor/services/user.py | 84 ------ shopfloor/services/validator.py | 247 ------------------ shopfloor/services/zone_picking.py | 36 +-- shopfloor/tests/__init__.py | 4 - shopfloor/tests/common.py | 154 +---------- shopfloor/tests/test_actions_data.py | 169 +----------- shopfloor/tests/test_actions_data_base.py | 222 ++++++++++++++++ shopfloor/tests/test_actions_data_detail.py | 4 +- shopfloor/tests/test_app.py | 31 --- shopfloor/tests/test_checkout_base.py | 2 +- shopfloor/tests/test_cluster_picking_base.py | 2 +- shopfloor/tests/test_cluster_picking_batch.py | 2 +- shopfloor/tests/test_delivery_base.py | 2 +- .../test_location_content_transfer_base.py | 4 +- ...on_content_transfer_set_destination_all.py | 2 +- ...ransfer_set_destination_package_or_line.py | 4 +- .../test_location_content_transfer_single.py | 2 +- shopfloor/tests/test_menu.py | 27 -- shopfloor/tests/test_menu_base.py | 36 +-- shopfloor/tests/test_openapi.py | 19 +- shopfloor/tests/test_picking_form.py | 2 +- shopfloor/tests/test_profile.py | 26 -- shopfloor/tests/test_single_pack_transfer.py | 2 +- .../tests/test_single_pack_transfer_base.py | 2 +- shopfloor/tests/test_zone_picking_base.py | 2 +- .../tests/test_zone_picking_unload_all.py | 2 +- ...est_zone_picking_unload_set_destination.py | 2 +- .../tests/test_zone_picking_unload_single.py | 2 +- shopfloor/utils.py | 10 + shopfloor/views/menus.xml | 21 -- shopfloor/views/shopfloor_menu.xml | 117 --------- shopfloor/views/shopfloor_profile_views.xml | 57 ---- 62 files changed, 412 insertions(+), 1564 deletions(-) delete mode 100644 shopfloor/actions/base_action.py delete mode 100644 shopfloor/controllers/__init__.py delete mode 100644 shopfloor/controllers/main.py create mode 100644 shopfloor/data/shopfloor_scenario_data.xml delete mode 100644 shopfloor/demo/auth_api_key_demo.xml delete mode 100644 shopfloor/demo/shopfloor_profile_demo.xml delete mode 100644 shopfloor/models/shopfloor_profile.py delete mode 100644 shopfloor/services/app.py delete mode 100644 shopfloor/services/profile.py delete mode 100644 shopfloor/services/user.py delete mode 100644 shopfloor/services/validator.py create mode 100644 shopfloor/tests/test_actions_data_base.py delete mode 100644 shopfloor/tests/test_app.py delete mode 100644 shopfloor/tests/test_menu.py delete mode 100644 shopfloor/tests/test_profile.py create mode 100644 shopfloor/utils.py delete mode 100644 shopfloor/views/menus.xml delete mode 100644 shopfloor/views/shopfloor_menu.xml delete mode 100644 shopfloor/views/shopfloor_profile_views.xml diff --git a/shopfloor/__init__.py b/shopfloor/__init__.py index 6a34e5681f2..31f5a21b83d 100644 --- a/shopfloor/__init__.py +++ b/shopfloor/__init__.py @@ -1,4 +1,3 @@ -from . import controllers from . import models from . import actions from . import services diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 652a259b3d2..a5ec72c51a5 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -15,6 +15,7 @@ "license": "AGPL-3", "application": True, "depends": [ + "shopfloor_base", "stock", "stock_picking_batch", "base_jsonify", @@ -45,6 +46,7 @@ "product_packaging_type", ], "data": [ + "security/groups.xml", "security/ir.model.access.csv", "views/shopfloor_menu.xml", "views/stock_picking_type.xml", @@ -59,5 +61,5 @@ "demo/shopfloor_menu_demo.xml", "demo/shopfloor_profile_demo.xml", ], - "installable": False, + "demo": ["demo/stock_picking_type_demo.xml", "demo/shopfloor_menu_demo.xml"], } diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index 43f307a745a..0192f5cd2b1 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -1,25 +1,8 @@ -""" -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 - -An Action component can be get from Service or Action Components using -``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 -Services. - -""" -from . import base_action from . import change_package_lot from . import data from . import data_detail +from . import schema +from . import schema_detail from . import completion_info from . import location_content_transfer_sorter from . import message diff --git a/shopfloor/actions/base_action.py b/shopfloor/actions/base_action.py deleted file mode 100644 index fb7eb2ae123..00000000000 --- a/shopfloor/actions/base_action.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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 - - -class ShopFloorProcessAction(AbstractComponent): - """Base Component for actions""" - - _name = "shopfloor.process.action" - _collection = "shopfloor.action" - _usage = "actions" - - def actions_for(self, usage): - return self.component(usage=usage) - - @property - def msg_store(self): - return self.actions_for("message") diff --git a/shopfloor/actions/change_package_lot.py b/shopfloor/actions/change_package_lot.py index 82876f1d04b..18a89aae338 100644 --- a/shopfloor/actions/change_package_lot.py +++ b/shopfloor/actions/change_package_lot.py @@ -61,7 +61,7 @@ def _change_pack_lot_change_lot( def is_lesser(value, other, rounding): return float_compare(value, other, precision_rounding=rounding) == -1 - inventory = self.actions_for("inventory") + inventory = self._actions_for("inventory") product = move_line.product_id if lot.product_id != product: return response_error_func( diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 8d59f2d27b0..2b7a5bc2545 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -1,61 +1,13 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from functools import wraps - from odoo import fields from odoo.addons.component.core import Component - - -def ensure_model(model_name): - """Decorator to ensure data method is called w/ the right recordset.""" - - def _ensure_model(func): - @wraps(func) - def wrapped(*args, **kwargs): - # 1st arg is `self` - record = args[1] - if record is not None: - assert ( - record._name == model_name - ), f"Expected model: {model_name}. Got: {record._name}" - return func(*args, **kwargs) - - return wrapped - - return _ensure_model +from odoo.addons.shopfloor_base.utils import ensure_model 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" - - 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 _simple_record_parser(self): - return ["id", "name"] - - @ensure_model("res.partner") - 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 self._simple_record_parser() + _inherit = "shopfloor.data.action" @ensure_model("stock.location") def location(self, record, **kw): diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py index 7ecb450832e..8523ca88b7c 100644 --- a/shopfloor/actions/data_detail.py +++ b/shopfloor/actions/data_detail.py @@ -3,14 +3,11 @@ from odoo.tools.float_utils import float_round from odoo.addons.component.core import Component +from odoo.addons.shopfloor_base.utils import ensure_model class DataDetailAction(Component): - """Provide extra data on top of data action.""" - - _name = "shopfloor.data.detail.action" - _inherit = "shopfloor.data.action" - _usage = "data_detail" + _inherit = "shopfloor.data.detail.action" def _select_value_to_label(self, rec, fname): return rec._fields[fname].convert_to_export(rec[fname], rec) @@ -33,6 +30,7 @@ def _location_detail_parser(self): ), ] + @ensure_model("stock.picking") def picking_detail(self, record, **kw): return self._jsonify(record, self._picking_detail_parser, **kw) @@ -75,6 +73,7 @@ def _package_detail_parser(self): ("location_id:location", ["id", "display_name:name"]), ] + @ensure_model("stock.production.lot") 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) @@ -93,6 +92,7 @@ def _lot_detail_parser(self): ), ] + @ensure_model("product.product") 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) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 053704db186..2968a312912 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -6,18 +6,7 @@ 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" + _inherit = "shopfloor.message.action" def no_picking_type(self): return { diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index 4592653ab8b..7a316051e70 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -14,6 +14,8 @@ class SearchAction(Component): _inherit = "shopfloor.process.action" _usage = "search" + # TODO: these methods shall be probably replaced by scan anything handlers + def location_from_scan(self, barcode): if not barcode: return self.env["stock.location"].browse() diff --git a/shopfloor/controllers/__init__.py b/shopfloor/controllers/__init__.py deleted file mode 100644 index 12a7e529b67..00000000000 --- a/shopfloor/controllers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import main diff --git a/shopfloor/controllers/main.py b/shopfloor/controllers/main.py deleted file mode 100644 index 218e08e3b74..00000000000 --- a/shopfloor/controllers/main.py +++ /dev/null @@ -1,11 +0,0 @@ -# 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.addons.base_rest.controllers import main - - -class ShopfloorController(main.RestController): - _root_path = "/shopfloor/" - _collection_name = "shopfloor.service" - _default_auth = "api_key" diff --git a/shopfloor/data/shopfloor_scenario_data.xml b/shopfloor/data/shopfloor_scenario_data.xml new file mode 100644 index 00000000000..3c13a0c098d --- /dev/null +++ b/shopfloor/data/shopfloor_scenario_data.xml @@ -0,0 +1,26 @@ + + + Single Pack Transfer + single_pack_transfer + + + Zone Picking + zone_picking + + + Cluster Picking + cluster_picking + + + Checkout + checkout + + + Delivery + delivery + + + Location content transfer + location_content_transfer + + diff --git a/shopfloor/demo/auth_api_key_demo.xml b/shopfloor/demo/auth_api_key_demo.xml deleted file mode 100644 index 7858d1d0442..00000000000 --- a/shopfloor/demo/auth_api_key_demo.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - Demo - - 72B044F7AC780DAC - - diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 036a20b724e..5360865ca5f 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -3,7 +3,7 @@ Single Pallet Transfer 20 - single_pack_transfer + Zone Picking 35 - zone_picking + Cluster Picking 30 - cluster_picking + Checkout 40 - checkout + Delivery 50 - delivery + Location Content Transfer 60 - location_content_transfer + - - Highbay Truck - - - Shelf 1 - - diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py index cb9a9dd4066..4da30d37885 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -2,7 +2,6 @@ from . import shopfloor_menu from . import shopfloor_log from . import stock_picking_type -from . import shopfloor_profile from . import stock_inventory from . import stock_location from . import stock_move diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 0e154243d09..e9dae5fd40e 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -2,14 +2,12 @@ # 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" - _description = "Menu displayed in the scanner application" - _order = "sequence" + _inherit = "shopfloor.menu" + # TODO: replace w/ options on shopfloor.scenario + # TODO: check if migration step is required for old `options` stored on menu _scenario_allowing_create_moves = ( "single_pack_transfer", "location_content_transfer", @@ -25,28 +23,9 @@ class ShopfloorMenu(models.Model): "location_content_transfer", ) - name = fields.Char(translate=True) - sequence = fields.Integer() - profile_id = fields.Many2one( - "shopfloor.profile", string="Profile", help="Visible on this profile only" - ) picking_type_ids = fields.Many2many( comodel_name="stock.picking.type", string="Operation Types", required=True ) - - 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 # can be used or not @@ -78,18 +57,7 @@ class ShopfloorMenu(models.Model): ) active = fields.Boolean(default=True) - 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"), - ("location_content_transfer", "Location Content Transfer"), - ] - - @api.depends("scenario", "picking_type_ids") + @api.depends("scenario_id", "picking_type_ids") def _compute_move_create_is_possible(self): for menu in self: menu.move_create_is_possible = bool( @@ -101,7 +69,7 @@ def _compute_move_create_is_possible(self): 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") + @api.constrains("scenario_id", "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: @@ -109,7 +77,7 @@ def _check_allow_move_create(self): _("Creation of moves is not allowed for menu {}.").format(menu.name) ) - @api.depends("scenario", "picking_type_ids") + @api.depends("scenario_id", "picking_type_ids") def _compute_unreserve_other_moves_is_possible(self): for menu in self: menu.unreserve_other_moves_is_possible = ( @@ -120,7 +88,7 @@ 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") + @api.depends("scenario_id", "picking_type_ids") def _compute_ignore_no_putaway_available_is_possible(self): for menu in self: menu.ignore_no_putaway_available_is_possible = bool( @@ -131,7 +99,7 @@ def _compute_ignore_no_putaway_available_is_possible(self): 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") + @api.constrains("scenario_id", "picking_type_ids", "ignore_no_putaway_available") def _check_ignore_no_putaway_available(self): for menu in self: if ( @@ -144,7 +112,7 @@ def _check_ignore_no_putaway_available(self): ) ) - @api.constrains("scenario", "picking_type_ids", "allow_unreserve_other_moves") + @api.constrains("scenario_id", "picking_type_ids", "allow_unreserve_other_moves") def _check_allow_unreserve_other_moves(self): for menu in self: if ( @@ -164,16 +132,15 @@ def _check_allow_unreserve_other_moves(self): # TODO: add tests. _move_entire_packs_scenario = ("single_pack_transfer", "delivery") - @api.constrains("scenario", "picking_type_ids") + @api.constrains("scenario_id", "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) + scenario_name = menu.scenario_id.name raise exceptions.ValidationError( _( "Scenario `{}` require(s) " diff --git a/shopfloor/models/shopfloor_profile.py b/shopfloor/models/shopfloor_profile.py deleted file mode 100644 index 060df4355e7..00000000000 --- a/shopfloor/models/shopfloor_profile.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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 - - -class ShopfloorProfile(models.Model): - _name = "shopfloor.profile" - _description = "Shopfloor profile settings" - - name = fields.Char(required=True) - menu_ids = fields.One2many( - comodel_name="shopfloor.menu", - inverse_name="profile_id", - string="Menus", - help="Menus visible for this profile", - ) - active = fields.Boolean(default=True) diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv index a3454e230ea..b4273b2dda3 100644 --- a/shopfloor/security/ir.model.access.csv +++ b/shopfloor/security/ir.model.access.csv @@ -1,6 +1,2 @@ "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","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","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","base.group_user",1,0,0,0 diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 7fe2f664ea1..924fa1c5ae5 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -1,14 +1,8 @@ # core classes from . import service -from . import validator -from . import schema -from . import schema_detail # generic services -from . import app -from . import user from . import menu -from . import profile from . import scan_anything # process services diff --git a/shopfloor/services/app.py b/shopfloor/services/app.py deleted file mode 100644 index ecbc8f21c08..00000000000 --- a/shopfloor/services/app.py +++ /dev/null @@ -1,61 +0,0 @@ -# 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 ShopfloorApp(Component): - """Generic endpoints for the Application.""" - - _inherit = "base.shopfloor.service" - _name = "shopfloor.app" - _usage = "app" - _description = __doc__ - - # 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()) - user_comp = self.component("user") - user_info = user_comp._user_info() - return self._response(data={"profiles": profiles, "user_info": user_info}) - - -class ShopfloorAppValidator(Component): - """Validators for the Application endpoints""" - - _inherit = "base.shopfloor.validator" - _name = "shopfloor.app.validator" - _usage = "app.validator" - - def user_config(self): - return {} - - -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): - profile_return_validator = self.component("profile.validator.response") - user_return_validator = self.component("user.validator.response") - return self._response_schema( - { - "profiles": { - "type": "list", - "required": True, - "schema": { - "type": "dict", - "schema": profile_return_validator._record_schema, - }, - }, - "user_info": { - "type": "dict", - "required": True, - "schema": user_return_validator._user_info_schema(), - }, - } - ) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index b0163d32171..2cc84ee6810 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -8,7 +8,7 @@ from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component -from .service import to_float +from ..utils import to_float class Checkout(Component): @@ -159,7 +159,7 @@ def scan_document(self, barcode): * summary: stock.picking is selected and all its lines have a destination pack set """ - search = self.actions_for("search") + search = self._actions_for("search") picking = search.picking_from_scan(barcode) if not picking: location = search.location_from_scan(barcode) @@ -336,7 +336,7 @@ def scan_line(self, picking_id, barcode): if message: return self._response_for_select_document(message=message) - search = self.actions_for("search") + search = self._actions_for("search") selection_lines = self._lines_to_pack(picking) if not selection_lines: @@ -712,7 +712,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): message = self._check_picking_status(picking) if message: return self._response_for_select_document(message=message) - search = self.actions_for("search") + search = self._actions_for("search") selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() @@ -861,7 +861,7 @@ def scan_dest_package(self, picking_id, selected_line_ids, barcode): 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") + search = self._actions_for("search") package = search.package_from_scan(barcode) return self._set_dest_package_from_selection(picking, lines, package) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index bd36ba886ce..64154c9d042 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -6,7 +6,7 @@ from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component -from .service import to_float +from ..utils import to_float class ClusterPicking(Component): @@ -442,7 +442,7 @@ def scan_line(self, picking_batch_id, move_line_id, barcode): batch, message=self.msg_store.operation_not_found() ) - search = self.actions_for("search") + search = self._actions_for("search") picking = move_line.picking_id @@ -588,7 +588,7 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit message=self.msg_store.unable_to_pick_more(move_line.product_uom_qty), ) - search = self.actions_for("search") + search = self._actions_for("search") bin_package = search.package_from_scan(barcode) if not bin_package: return self._response_for_scan_destination( @@ -714,7 +714,7 @@ def is_zero(self, picking_batch_id, move_line_id, zero): ) if not zero: - inventory = self.actions_for("inventory") + inventory = self._actions_for("inventory") inventory.create_draft_check_empty( move_line.location_id, move_line.product_id, @@ -794,7 +794,7 @@ def stock_issue(self, picking_batch_id, move_line_id): batch, message=self.msg_store.operation_not_found() ) - inventory = self.actions_for("inventory") + inventory = self._actions_for("inventory") # create a draft inventory for a user to check inventory.create_control_stock( move_line.location_id, @@ -874,10 +874,10 @@ def change_pack_lot(self, picking_batch_id, move_line_id, barcode): return self._pick_next_line( batch, message=self.msg_store.operation_not_found() ) - search = self.actions_for("search") + 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") + change_package_lot = self._actions_for("change.package.lot") lot = search.lot_from_scan(barcode) if lot: response = change_package_lot.change_lot( @@ -929,7 +929,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): 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) + scanned_location = self._actions_for("search").location_from_scan(barcode) if not scanned_location: return self._response_for_unload_all( batch, message=self.msg_store.no_location_found() @@ -948,7 +948,7 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): return self._response_for_confirm_unload_all(batch) self._unload_write_destination_on_lines(lines, scanned_location) - completion_info = self.actions_for("completion.info") + 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) @@ -1095,7 +1095,7 @@ def _unload_scan_destination_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) + scanned_location = self._actions_for("search").location_from_scan(barcode) if not scanned_location: return self._response_for_unload_set_destination( batch, package, message=self.msg_store.no_location_found() @@ -1116,7 +1116,7 @@ def _unload_scan_destination_lines( self._unload_write_destination_on_lines(lines, scanned_location) - completion_info = self.actions_for("completion.info") + completion_info = self._actions_for("completion.info") completion_info_popup = completion_info.popup(lines) return self._unload_next_package( diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 072b6bc710d..e965ec48c5a 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -112,7 +112,7 @@ def scan_deliver(self, barcode, picking_id=None): * deliver: always return here with the data for the last touched picking or no picking if the picking has been set to done """ - search = self.actions_for("search") + search = self._actions_for("search") picking = search.picking_from_scan(barcode) barcode_valid = bool(picking) if picking: diff --git a/shopfloor/services/forms/__init__.py b/shopfloor/services/forms/__init__.py index 8d328d097a7..8acf220a829 100644 --- a/shopfloor/services/forms/__init__.py +++ b/shopfloor/services/forms/__init__.py @@ -1,2 +1 @@ -from . import form_mixin from . import picking_form diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 2b9912fe84d..d645e57b2b0 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -5,7 +5,7 @@ from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component -from .service import to_float +from ..utils import to_float # NOTE for the implementation: share several similarities with the "cluster # picking" scenario @@ -103,7 +103,7 @@ def _response_for_scan_destination( 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") + sorter = self._actions_for("location_content_transfer.sorter") sorter.feed_pickings(pickings) lines = sorter.move_lines() package_levels = sorter.package_levels() @@ -130,7 +130,7 @@ 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 = self._actions_for("location_content_transfer.sorter") sorter.feed_pickings(pickings) try: next_content = next(sorter) @@ -284,14 +284,14 @@ def scan_location(self, barcode): levels have the same destination * start_single: if any line or package level has a different destination """ - location = self.actions_for("search").location_from_scan(barcode) + 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.picking_id picking_types = pickings.mapped("picking_type_id") - savepoint = self.actions_for("savepoint").new() + savepoint = self._actions_for("savepoint").new() unreserved_moves = self.env["stock.move"].browse() if self.work.menu.allow_unreserve_other_moves: @@ -421,7 +421,7 @@ def _write_destination_on_lines(self, lines, location): def _set_all_destination_lines_and_done(self, pickings, move_lines, dest_location): self._write_destination_on_lines(move_lines, dest_location) - stock = self.actions_for("stock") + stock = self._actions_for("stock") stock.validate_moves(move_lines.move_id) def _lock_lines(self, lines): @@ -447,7 +447,7 @@ def set_destination_all(self, location_id, barcode, confirmation=False): # 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) + 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() @@ -473,7 +473,7 @@ 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 = 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( @@ -524,7 +524,7 @@ def scan_package(self, location_id, package_level_id, barcode): message=self.msg_store.record_not_found(), ) - search = self.actions_for("search") + 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) @@ -586,7 +586,7 @@ def scan_line(self, location_id, move_line_id, barcode): message=self.msg_store.record_not_found(), ) - search = self.actions_for("search") + search = self._actions_for("search") package = search.package_from_scan(barcode) if package and move_line.package_id == package: @@ -636,7 +636,7 @@ def set_destination_package( if not package_level.exists(): move_lines = self._find_transfer_move_lines(location) return self._response_for_start_single(move_lines.mapped("picking_id")) - search = self.actions_for("search") + search = self._actions_for("search") scanned_location = search.location_from_scan(barcode) if not scanned_location: return self._response_for_scan_destination( @@ -668,13 +668,13 @@ 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) - stock = self.actions_for("stock") + 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 ) - completion_info = self.actions_for("completion.info") + 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"), @@ -705,7 +705,7 @@ def set_destination_line( if not move_line.exists(): move_lines = self._find_transfer_move_lines(location) return self._response_for_start_single(move_lines.mapped("picking_id")) - search = self.actions_for("search") + search = self._actions_for("search") scanned_location = search.location_from_scan(barcode) if not scanned_location: return self._response_for_scan_destination( @@ -743,13 +743,13 @@ 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) - stock = self.actions_for("stock") + 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 ) - completion_info = self.actions_for("completion.info") + 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"), @@ -817,7 +817,7 @@ def stock_out_package(self, location_id, 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")) - inventory = self.actions_for("inventory") + inventory = self._actions_for("inventory") package_move_lines = package_level.move_line_ids package_moves = package_move_lines.mapped("move_id") for package_move in package_moves: @@ -875,7 +875,7 @@ def stock_out_line(self, location_id, 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")) - inventory = self.actions_for("inventory") + inventory = self._actions_for("inventory") move_line.move_id.split_other_move_lines(move_line) move_line_src_location = move_line.location_id move = move_line.move_id diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index ca353b7d847..a0f672e190b 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -1,57 +1,14 @@ # 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 from odoo.addons.component.core import Component class ShopfloorMenu(Component): - """ - Menu Structure for the client application. - - The list of menus is restricted by the profiles. - A menu without profile is shown in every profiles. - """ - - _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() - return expression.AND( - [ - base_domain, - [ - "|", - ("profile_id", "=", False), - ("profile_id", "=", self.work.profile.id), - ], - ] - ) - - def _search(self, name_fragment=None): - if not self.work.profile: - # we need to know 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) - return records - - def search(self, name_fragment=None): - """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)} - ) + _inherit = "shopfloor.menu" def _convert_one_record(self, record): - values = record.jsonify(self._one_record_parser, one=True) + values = super()._convert_one_record(record) counters = self._get_move_line_counters(record) values.update(counters) return values @@ -60,60 +17,30 @@ 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( + 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", + return super()._one_record_parser() + [ ("picking_type_ids:picking_types", ["id", "name"]), ] -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} - } - - class ShopfloorMenuValidatorResponse(Component): """Validators for the Menu endpoints responses""" - _inherit = "base.shopfloor.validator.response" - _name = "shopfloor.menu.validator.response" - _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": self.schemas._schema_list_of(record_schema), - } - ) + _inherit = "shopfloor.menu.validator.response" @property def _record_schema(self): - 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": self.schemas._schema_list_of(self._picking_type_schema), - } + schema = super()._record_schema + schema.update( + {"picking_types": self.schemas._schema_list_of(self._picking_type_schema)} + ) schema.update(self.schemas.move_lines_counters()) return schema diff --git a/shopfloor/services/profile.py b/shopfloor/services/profile.py deleted file mode 100644 index 6724ecaca14..00000000000 --- a/shopfloor/services/profile.py +++ /dev/null @@ -1,80 +0,0 @@ -# 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 - - -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 HTTP_SERVICE_CTX_PROFILE_ID. - - Only stock managers should be allowed to change the profile for a device. - """ - - _inherit = "base.shopfloor.service" - _name = "shopfloor.profile" - _usage = "profile" - _expose_model = "shopfloor.profile" - _description = __doc__ - - 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 records - - def search(self, name_fragment=None): - """List available profiles""" - 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 { - "id": record.id, - "name": record.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} - } - - -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_schema}, - }, - } - ) - - @property - def _record_schema(self): - return { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - } diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index cca0143f446..4bb6dde6053 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -235,100 +235,12 @@ def msg_store(self): @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): - """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 callable(active): - active = active(request, method_name) - if not active: - continue - header_name, coerce_func, ctx_value_handler_name, mandatory = rule - try: - header_value = coerce_func(headers.get(header_name)) - except (TypeError, ValueError) as err: - if not mandatory: - continue - 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, mandatory - "HTTP_SERVICE_CTX_MENU_ID", - int, - "_work_ctx_get_menu_id", - True, - ) - PROFILE_ID_HEADER_RULE = ( - # header name, coerce func, ctx value handler, mandatory - "HTTP_SERVICE_CTX_PROFILE_ID", - int, - "_work_ctx_get_profile_id", - True, - ) - - 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() - - _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 + return self._actions_for("search_move_line", propagate_kwargs=["picking_types"]) 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 + _inherit = "base.shopfloor.process" def _get_process_picking_types(self): """Return picking types for the menu""" @@ -352,7 +264,7 @@ def search_move_line(self): # 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) + 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. diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 415847b06ff..97d50216152 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -58,7 +58,7 @@ def _response_for_scan_location( ) def start(self, barcode, confirmation=False): - search = self.actions_for("search") + search = self._actions_for("search") picking_types = self.picking_types location = search.location_from_scan(barcode) @@ -103,7 +103,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 = self.actions_for("savepoint").new() + 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( @@ -215,7 +215,7 @@ 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") + search = self._actions_for("search") package_level = self.env["stock.package_level"].browse(package_level_id) if not package_level.exists(): @@ -266,7 +266,7 @@ def _router_validate_success(self, package_level): completion_info_popup = None if self._is_last_move(move): - completion_info = self.actions_for("completion.info") + 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) @@ -274,7 +274,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 - stock = self.actions_for("stock") + stock = self._actions_for("stock") stock.validate_moves(move) def cancel(self, package_level_id): diff --git a/shopfloor/services/user.py b/shopfloor/services/user.py deleted file mode 100644 index ceed40c9ea6..00000000000 --- a/shopfloor/services/user.py +++ /dev/null @@ -1,84 +0,0 @@ -# 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 - - -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}) - - # TODO: this endpoint does not require profile header - def user_info(self): - return self._response(data={"user_info": self._user_info()}) - - def _user_info(self): - return self.env.user.jsonify(self._user_info_parser, one=True) - - @property - def _user_info_parser(self): - return ["id", "name"] - - -class ShopfloorUserValidator(Component): - """Validators for the User endpoints""" - - _inherit = "base.shopfloor.validator" - _name = "shopfloor.user.validator" - _usage = "user.validator" - - def menu(self): - return {} - - def user_info(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, - }, - }, - } - ) - - def user_info(self): - return self._response_schema( - { - "user_info": { - "type": "dict", - "required": True, - "schema": self._user_info_schema(), - } - } - ) - - def _user_info_schema(self): - return { - "id": {"coerce": to_int, "required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - } diff --git a/shopfloor/services/validator.py b/shopfloor/services/validator.py deleted file mode 100644 index 717fd9296f2..00000000000 --- a/shopfloor/services/validator.py +++ /dev/null @@ -1,247 +0,0 @@ -# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -import logging - -from odoo.addons.component.core import AbstractComponent, Component -from odoo.addons.component.exception import NoComponentError - -_logger = logging.getLogger(__name__) - - -class ShopfloorRestCerberusValidator(Component): - """Customize the handling of validators - - 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.response" 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")], - input_param=restapi.CerberusValidator("_get_partner_input_schema"), - output_param=restapi.CerberusValidator("_get_partner_output_schema"), - auth="public", - ) - - The schema is get by calling the method "_get..." on the service. - - 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 # noqa - - The following change in base_rest allows to customize the way the validator - handler is get: https://github.com/OCA/rest-framework/pull/99 - - This is what is used here to delegate to our ".validator" and - ".validator.response" components. - """ - - _name = "shopfloor.rest.cerberus.validator" - _inherit = "base.rest.cerberus.validator" - _usage = "cerberus.validator" - _collection = "shopfloor.service" - _is_rest_service_component = False - - def _get_validator_component(self, service, method_name, direction): - assert direction in ("input", "output") - if direction == "input": - suffix = "validator" - method_name = method_name.replace("_validator_", "") - else: - suffix = "validator.response" - method_name = method_name.replace("_validator_return_", "") - validator_component = self.component( - usage="{}.{}".format(service._usage, suffix) - ) - return validator_component, method_name - - def get_validator_handler(self, service, method_name, direction): - """Get the validator handler for a method - - By default, it returns the method on the current service instance. It - can be customized to delegate the validators to another component. - """ - try: - validator_component, method_name = self._get_validator_component( - service, method_name, direction - ) - except NoComponentError: - _logger.warning("no component found for %s method %s", service, method_name) - return {} - - try: - return getattr(validator_component, method_name) - except AttributeError: - _logger.warning( - "no validator method found for %s method %s", service, method_name - ) - return {} - - def has_validator_handler(self, service, method_name, direction): - """Return if the service has a validator handler for a method - - By default, it returns True if the the method exists on the service. It - can be customized to delegate the validators to another component. - """ - try: - validator_component, method_name = self._get_validator_component( - service, method_name, direction - ) - except NoComponentError: - return False - return hasattr(validator_component, method_name) - - -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 - - 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 - - # Initial state of a workflow - _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 {} - - @property - 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 - - 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. - - 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": { - "type": "dict", - "required": False, - "schema": { - "message_type": { - "type": "string", - "required": True, - "allowed": ["info", "warning", "error", "success"], - }, - "body": {"type": "string", "required": True}, - }, - }, - "popup": { - "type": "dict", - "required": False, - "schema": {"body": {"type": "string", "required": True}}, - }, - "log_entry_url": {"type": "string", "required": False}, - } - if not data_schema: - 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( - "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/services/zone_picking.py b/shopfloor/services/zone_picking.py index 7d0891cedfb..910ecc15b91 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -9,7 +9,7 @@ from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component -from .service import to_float +from ..utils import to_float class ZonePicking(Component): @@ -210,7 +210,7 @@ def _response_for_unload_all( def _response_for_unload_single(self, move_line, message=None, popup=None): buffer_lines = self._find_buffer_move_lines() - completion_info = self.actions_for("completion.info") + completion_info = self._actions_for("completion.info") completion_info_popup = completion_info.popup(buffer_lines) return self._response( next_state="unload_single", @@ -394,7 +394,7 @@ def scan_location(self, barcode): * start: invalid barcode * select_picking_type: the location is valid, user has to choose a picking type """ - search = self.actions_for("search") + 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()) @@ -429,7 +429,7 @@ def _scan_source_location(self, barcode): """ response = None message = None - search = self.actions_for("search") + search = self._actions_for("search") location = search.location_from_scan(barcode) if not location: return response, message @@ -471,7 +471,7 @@ def _scan_source_package(self, barcode): """ message = None response = None - search = self.actions_for("search") + search = self._actions_for("search") package = search.package_from_scan(barcode) if not package: return response, message @@ -488,7 +488,7 @@ def _scan_source_product(self, barcode): """ message = None response = None - search = self.actions_for("search") + search = self._actions_for("search") product = search.product_from_scan(barcode) if not product: return response, message @@ -505,7 +505,7 @@ def _scan_source_lot(self, barcode): """ message = None response = None - search = self.actions_for("search") + search = self._actions_for("search") lot = search.lot_from_scan(barcode) if not lot: return response, message @@ -604,7 +604,7 @@ def _set_destination_location(self, move_line, quantity, confirmation, 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() - stock = self.actions_for("stock") + stock = self._actions_for("stock") stock.validate_moves(move_line.move_id) location_changed = True # Zero check @@ -741,7 +741,7 @@ def set_destination( return self._response_for_start(message=self.msg_store.record_not_found()) pkg_moved = False - search = self.actions_for("search") + search = self._actions_for("search") accept_only_package = not self._move_line_full_qty(move_line, quantity) if not accept_only_package: @@ -796,7 +796,7 @@ def is_zero(self, move_line_id, zero): 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 = 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 @@ -859,7 +859,7 @@ def stock_issue(self, move_line_id): 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") + inventory = self._actions_for("inventory") # create a draft inventory for a user to check inventory.create_control_stock( move_line.location_id, @@ -911,14 +911,14 @@ def change_pack_lot(self, move_line_id, barcode): 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") + search = self._actions_for("search") # 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) response_error_func = functools.partial(self._response_for_change_pack_lot) response = None - change_package_lot = self.actions_for("change.package.lot") + change_package_lot = self._actions_for("change.package.lot") # handle lot lot = search.lot_from_scan(barcode) if lot: @@ -1010,7 +1010,7 @@ def set_destination_all(self, barcode, confirmation=False): expected one but is valid (in picking type's default destination) * select_line: no remaining move lines in buffer """ - search = self.actions_for("search") + search = self._actions_for("search") location = search.location_from_scan(barcode) message = None buffer_lines = self._find_buffer_move_lines() @@ -1045,7 +1045,7 @@ def set_destination_all(self, barcode, confirmation=False): self._write_destination_on_lines(buffer_lines, location) # set lines to done + refresh buffer lines (should be empty) moves = buffer_lines.mapped("move_id") - stock = self.actions_for("stock") + stock = self._actions_for("stock") stock.validate_moves(moves) message = self.msg_store.buffer_complete() buffer_lines = self._find_buffer_move_lines() @@ -1123,7 +1123,7 @@ def unload_scan_pack(self, package_id, barcode): return self._unload_response( unload_single_message=self.msg_store.record_not_found(), ) - search = self.actions_for("search") + search = self._actions_for("search") scanned_package = search.package_from_scan(barcode) # the scanned barcode matches the package if scanned_package == package: @@ -1166,7 +1166,7 @@ def unload_set_destination(self, package_id, barcode, confirmation=False): return self._response_for_select_line( move_lines, message=self.msg_store.record_not_found(), ) - search = self.actions_for("search") + search = self._actions_for("search") location = search.location_from_scan(barcode) if location: if not location.is_sublocation_of( @@ -1201,7 +1201,7 @@ def unload_set_destination(self, package_id, barcode, confirmation=False): for move in moves: move.split_other_move_lines(buffer_lines & move.move_line_ids) - stock = self.actions_for("stock") + stock = self._actions_for("stock") stock.validate_moves(moves) buffer_lines = self._find_buffer_move_lines() diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 7749abc9871..4715a0bf2bd 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -1,9 +1,5 @@ -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 from . import test_actions_data from . import test_actions_data_detail diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 2a445122eb3..b80dc507d6f 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -1,130 +1,17 @@ # 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 from odoo import models -from odoo.tests.common import Form, SavepointCase +from odoo.tests.common import Form -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 +from odoo.addons.shopfloor_base.tests.common import CommonCase as BaseCommonCase -class AnyObject: - def __repr__(self): - return "ANY" - - def __deepcopy__(self, memodict=None): - return self - - def __copy__(self): - return self - - def __eq__(self, other): - return True - - -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 - 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() # allow accepting anything in assert_response() - - maxDiff = None - - @contextmanager - def work_on_services(self, env=None, **params): - params = params or {} - collection = _PseudoCollection("shopfloor.service", env or self.env) - yield WorkContext( - 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 - 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, - _service_skip_request_validation=True, - ) - ) - - cls.setUpComponent() - cls.setUpRegistry() - 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: - 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: - 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) - +class CommonCase(BaseCommonCase): @classmethod def setUpClassVars(cls): + super().setUpClassVars() stock_location = cls.env.ref("stock.stock_location_stock") cls.stock_location = stock_location cls.customer_location = cls.env.ref("stock.stock_location_customers") @@ -134,8 +21,15 @@ def setUpClassVars(cls): cls.shelf1 = cls.env.ref("stock.stock_location_components") cls.shelf2 = cls.env.ref("stock.stock_location_14") + @classmethod + def _shopfloor_user_values(cls): + vals = super()._shopfloor_user_values() + vals["groups_id"] = [(6, 0, [cls.env.ref("stock.group_stock_user").id])] + return vals + @classmethod def setUpClassBaseData(cls): + super().setUpClassBaseData() cls.customer = cls.env["res.partner"].sudo().create({"name": "Customer"}) cls.customer_location.sudo().barcode = "CUSTOMERS" @@ -242,32 +136,6 @@ def setUpClassBaseData(cls): ) ) - 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 - value. - """ - 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 {}}} - ) - elif data: - expected["data"] = data - self.assertDictEqual( - response, - expected, - "\n\nActual:\n%s" - "\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"]) diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 3b51b83ca7f..6759c2b6096 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -1,172 +1,7 @@ # 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 - -_logger = logging.getLogger(__name__) - - -try: - from cerberus import Validator -except ImportError: - _logger.debug("Can not import cerberus") - - -class ActionsDataCaseBase(CommonCase): - @classmethod - 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): - super().setUpClassBaseData() - 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( - lines=[ - (cls.product_a, 10), - (cls.product_b, 10), - (cls.product_c, 10), - (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) - # 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.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.product_a_vendor = ( - cls.env["product.supplierinfo"] - .sudo() - .create( - { - "name": cls.supplier.id, - "price": 8.0, - "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) - self.assertTrue(validator.validate(data), validator.errors) - - def _expected_location(self, record, **kw): - data = { - "id": record.id, - "name": record.name, - "barcode": record.barcode, - } - data.update(kw) - return data - - def _expected_product(self, record, **kw): - data = { - "id": record.id, - "name": record.name, - "display_name": record.display_name, - "default_code": record.default_code, - "barcode": record.barcode, - "packaging": [ - self._expected_packaging(x) for x in record.packaging_ids if x.qty - ], - "uom": { - "factor": record.uom_id.factor, - "id": record.uom_id.id, - "name": record.uom_id.name, - "rounding": record.uom_id.rounding, - }, - "supplier_code": self._expected_supplier_code(record), - } - data.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, - "name": record.packaging_type_id.name, - "code": record.packaging_type_id.code, - "qty": record.qty, - } - 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 or record.estimated_pack_weight, - "storage_type": None, - } - data.update(kw) - return data +from .common import PickingBatchMixin +from .test_actions_data_base import ActionsDataCaseBase class ActionsDataCase(ActionsDataCaseBase): diff --git a/shopfloor/tests/test_actions_data_base.py b/shopfloor/tests/test_actions_data_base.py new file mode 100644 index 00000000000..fd33d53fe93 --- /dev/null +++ b/shopfloor/tests/test_actions_data_base.py @@ -0,0 +1,222 @@ +# 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.shopfloor_base.tests.common_misc import ActionsDataTestMixin + +from .common import CommonCase + + +class ActionsDataCaseBase(CommonCase, ActionsDataTestMixin): + @classmethod + 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): + super().setUpClassBaseData() + 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( + lines=[ + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (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) + # 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.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.product_a_vendor = ( + cls.env["product.supplierinfo"] + .sudo() + .create( + { + "name": cls.supplier.id, + "price": 8.0, + "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 _expected_location(self, record, **kw): + data = { + "id": record.id, + "name": record.name, + "barcode": record.barcode, + } + data.update(kw) + return data + + def _expected_product(self, record, **kw): + data = { + "id": record.id, + "name": record.name, + "display_name": record.display_name, + "default_code": record.default_code, + "barcode": record.barcode, + "packaging": [ + self._expected_packaging(x) for x in record.packaging_ids if x.qty + ], + "uom": { + "factor": record.uom_id.factor, + "id": record.uom_id.id, + "name": record.uom_id.name, + "rounding": record.uom_id.rounding, + }, + "supplier_code": self._expected_supplier_code(record), + } + data.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, + "name": record.packaging_type_id.name, + "code": record.packaging_type_id.code, + "qty": record.qty, + } + 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 or record.estimated_pack_weight, + "storage_type": None, + } + data.update(kw) + return data + + +class ActionsDataDetailCaseBase(ActionsDataCaseBase): + @classmethod + 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" + ) + + def _expected_location_detail(self, record, **kw): + return dict( + **self._expected_location(record), + **{ + "complete_name": record.complete_name, + "reserved_move_lines": self.data_detail.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) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 53bb743d1be..aa166ed2003 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -5,9 +5,7 @@ from PIL import Image -from odoo.tools.float_utils import float_round - -from .test_actions_data import ActionsDataCaseBase +from .test_actions_data_base import ActionsDataDetailCaseBase def fake_colored_image(color="#4169E1", size=(800, 500)): diff --git a/shopfloor/tests/test_app.py b/shopfloor/tests/test_app.py deleted file mode 100644 index 8a41ad8695a..00000000000 --- a/shopfloor/tests/test_app.py +++ /dev/null @@ -1,31 +0,0 @@ -# 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 AppCase(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="app") - - def test_user_config(self): - """Request /app/user_config""" - # Simulate the client asking the configuration - response = self.service.dispatch("user_config") - profiles = self.env["shopfloor.profile"].search([]) - self.assert_response( - response, - data={ - "profiles": [ - {"id": profile.id, "name": profile.name} for profile in profiles - ], - "user_info": {"id": self.env.user.id, "name": self.env.user.name}, - }, - ) diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 16b1986688d..e1d7517440d 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -8,7 +8,7 @@ class CheckoutCommonCase(CommonCase): 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.profile = cls.env.ref("shopfloor_base.profile_demo_1") cls.picking_type = cls.menu.picking_type_ids cls.wh = cls.picking_type.warehouse_id diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py index ae40265b871..941adec4ee1 100644 --- a/shopfloor/tests/test_cluster_picking_base.py +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -9,7 +9,7 @@ class ClusterPickingCommonCase(CommonCase, PickingBatchMixin): 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.profile = cls.env.ref("shopfloor_base.profile_demo_1") cls.picking_type = cls.menu.picking_type_ids cls.wh = cls.picking_type.warehouse_id diff --git a/shopfloor/tests/test_cluster_picking_batch.py b/shopfloor/tests/test_cluster_picking_batch.py index d30fca2dadd..fcb3f0642a7 100644 --- a/shopfloor/tests/test_cluster_picking_batch.py +++ b/shopfloor/tests/test_cluster_picking_batch.py @@ -9,7 +9,7 @@ class ClusterPickingBatchCase(CommonCase, PickingBatchMixin): 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.profile = cls.env.ref("shopfloor_base.profile_demo_1") cls.picking_type = cls.menu.picking_type_ids cls.wh = cls.picking_type.warehouse_id diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py index 8c80fd9f047..7c071f88f1f 100644 --- a/shopfloor/tests/test_delivery_base.py +++ b/shopfloor/tests/test_delivery_base.py @@ -9,7 +9,7 @@ class DeliveryCommonCase(CommonCase): 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.profile = cls.env.ref("shopfloor_base.profile_demo_1") cls.picking_type = cls.menu.picking_type_ids cls.wh = cls.picking_type.warehouse_id diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index 54568133e78..376a59c12ae 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -9,7 +9,7 @@ class LocationContentTransferCommonCase(CommonCase): 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.profile = cls.env.ref("shopfloor_base.profile_demo_1") cls.picking_type = cls.menu.picking_type_ids cls.wh = cls.picking_type.warehouse_id @@ -87,7 +87,7 @@ def assert_response_scan_destination_all( def assert_response_start_single( self, response, pickings, message=None, popup=None ): - sorter = self.service.actions_for("location_content_transfer.sorter") + sorter = self.service._actions_for("location_content_transfer.sorter") sorter.feed_pickings(pickings) location = pickings.mapped("location_id") self.assert_response( 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 89b2b0cd2ff..73b0d8a6544 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -207,7 +207,7 @@ def test_set_destination_all_dest_location_ok_with_completion_info(self): 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 = self.service._actions_for("completion.info") completion_info_popup = completion_info.popup(move_lines) self.assert_response_start( response, 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 1853edba35f..e090849a2f4 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 @@ -217,7 +217,7 @@ def test_set_destination_package_dest_location_ok_with_completion_info(self): 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 = self.service._actions_for("completion.info") completion_info_popup = completion_info.popup(package_level.move_line_ids) self.assert_response_start_single( response, @@ -409,7 +409,7 @@ def test_set_destination_line_dest_location_ok_with_completion_info(self): 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 = self.service._actions_for("completion.info") completion_info_popup = completion_info.popup(move_line) self.assert_response_start_single( response, diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index 831f7f97d57..46dd5291a3c 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -348,7 +348,7 @@ def test_postpone_sorter(self): move_line = self.picking2.move_line_ids[0] move_lines = self.service._find_transfer_move_lines(self.content_loc) pickings = move_lines.mapped("picking_id") - sorter = self.service.actions_for("location_content_transfer.sorter") + sorter = self.service._actions_for("location_content_transfer.sorter") sorter.feed_pickings(pickings) content_sorted1 = list(sorter) self.service.dispatch( diff --git a/shopfloor/tests/test_menu.py b/shopfloor/tests/test_menu.py deleted file mode 100644 index 652fcaee8cc..00000000000 --- a/shopfloor/tests/test_menu.py +++ /dev/null @@ -1,27 +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_menu_base import CommonMenuCase - - -class MenuCase(CommonMenuCase): - 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 - 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") - menus_without_profile.profile_id = other_profile - - response = self.service.dispatch("search") - - my_menus = menus - menus_without_profile - self._assert_menu_response(response, my_menus) diff --git a/shopfloor/tests/test_menu_base.py b/shopfloor/tests/test_menu_base.py index a85d4076779..6c3fb86d496 100644 --- a/shopfloor/tests/test_menu_base.py +++ b/shopfloor/tests/test_menu_base.py @@ -1,43 +1,33 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.shopfloor_base.tests.common_misc import MenuTestMixin + from .common import CommonCase -class CommonMenuCase(CommonCase): +class CommonMenuCase(CommonCase, MenuTestMixin): @classmethod def setUpClassVars(cls, *args, **kwargs): super().setUpClassVars(*args, **kwargs) - cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") 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, **kw): + data = super()._data_for_menu_item(menu, **kw) + expected_counters = kw.get("expected_counters") or {} + data.update( + { + "picking_types": [ + {"id": picking_type.id, "name": picking_type.name} + for picking_type in menu.picking_type_ids ], - }, + } ) - - 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, { diff --git a/shopfloor/tests/test_openapi.py b/shopfloor/tests/test_openapi.py index 561b0400728..019753f4b83 100644 --- a/shopfloor/tests/test_openapi.py +++ b/shopfloor/tests/test_openapi.py @@ -1,24 +1,19 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.shopfloor_base.tests.common_misc import OpenAPITestMixin + from .common import CommonCase -class TestOpenAPICommonCase(CommonCase): +class TestOpenAPICommonCase(CommonCase, OpenAPITestMixin): @classmethod - def setUpClassVars(cls, *args, **kwargs): - super().setUpClassVars(*args, **kwargs) - + def setUpClassVars(cls): + super().setUpClassVars() # 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") + cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") 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() + self._test_openapi(menu=self.menu, profile=self.profile) diff --git a/shopfloor/tests/test_picking_form.py b/shopfloor/tests/test_picking_form.py index 67e4f8696b3..5938fc1165c 100644 --- a/shopfloor/tests/test_picking_form.py +++ b/shopfloor/tests/test_picking_form.py @@ -8,7 +8,7 @@ class PickingFormCase(CommonCase): 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.profile = cls.env.ref("shopfloor_base.profile_demo_1") cls.picking_type = cls.menu.picking_type_ids cls.wh = cls.picking_type.warehouse_id diff --git a/shopfloor/tests/test_profile.py b/shopfloor/tests/test_profile.py deleted file mode 100644 index a7fc094ad6e..00000000000 --- a/shopfloor/tests/test_profile.py +++ /dev/null @@ -1,26 +0,0 @@ -# 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 ProfileCase(CommonCase): - def setUp(self): - super().setUp() - with self.work_on_services() as work: - self.service = work.component(usage="profile") - - 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"}, - {"id": self.ANY, "name": "Shelf 1"}, - ], - }, - ) diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 764e62f1271..01464d82b77 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -652,7 +652,7 @@ def test_validate_location_to_confirm(self): }, ) - message = self.service.actions_for("message").confirm_location_changed( + message = self.service._actions_for("message").confirm_location_changed( sub_shelf1, sub_shelf2 ) self.assert_response( diff --git a/shopfloor/tests/test_single_pack_transfer_base.py b/shopfloor/tests/test_single_pack_transfer_base.py index 05f76f166be..23030abee7a 100644 --- a/shopfloor/tests/test_single_pack_transfer_base.py +++ b/shopfloor/tests/test_single_pack_transfer_base.py @@ -9,7 +9,7 @@ class SinglePackTransferCommonBase(CommonCase): 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.profile = cls.env.ref("shopfloor_base.profile_demo_1") cls.picking_type = cls.menu.picking_type_ids cls.wh = cls.picking_type.warehouse_id diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 09b2c700114..0c3872358e5 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -8,7 +8,7 @@ class ZonePickingCommonCase(CommonCase): 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.profile = cls.env.ref("shopfloor_base.profile_demo_1") cls.picking_type = cls.menu.picking_type_ids cls.wh = cls.picking_type.warehouse_id diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py index 87f53036572..922768a6f35 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -297,7 +297,7 @@ def test_unload_split_buffer_multi_lines(self): response = self.service.dispatch("unload_split", params={},) # check response buffer_lines = self.service._find_buffer_move_lines() - completion_info = self.service.actions_for("completion.info") + completion_info = self.service._actions_for("completion.info") completion_info_popup = completion_info.popup(buffer_lines) self.assert_response_unload_single( response, diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index bf0cf7bdf5c..9bcfc3dac61 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -244,7 +244,7 @@ def test_unload_set_destination_ok_buffer_not_empty(self): # check response buffer_line = self.service._find_buffer_move_lines() - completion_info = self.service.actions_for("completion.info") + completion_info = self.service._actions_for("completion.info") completion_info_popup = completion_info.popup(buffer_line) self.assert_response_unload_single( response, diff --git a/shopfloor/tests/test_zone_picking_unload_single.py b/shopfloor/tests/test_zone_picking_unload_single.py index e719a8b9bb4..c5e2058b111 100644 --- a/shopfloor/tests/test_zone_picking_unload_single.py +++ b/shopfloor/tests/test_zone_picking_unload_single.py @@ -26,7 +26,7 @@ def test_unload_scan_pack_wrong_parameters(self): response = self.service.dispatch( "unload_scan_pack", params={"package_id": 1234567890, "barcode": "UNKNOWN"}, ) - completion_info = self.service.actions_for("completion.info") + completion_info = self.service._actions_for("completion.info") completion_info_popup = completion_info.popup(move_line) self.assert_response_unload_single( response, diff --git a/shopfloor/utils.py b/shopfloor/utils.py new file mode 100644 index 00000000000..7e88ea3b36c --- /dev/null +++ b/shopfloor/utils.py @@ -0,0 +1,10 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +def to_float(val): + if isinstance(val, float): + return val + if val: + return float(val) + return None diff --git a/shopfloor/views/menus.xml b/shopfloor/views/menus.xml deleted file mode 100644 index 29876a3a776..00000000000 --- a/shopfloor/views/menus.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml deleted file mode 100644 index 3f6c6e331bd..00000000000 --- a/shopfloor/views/shopfloor_menu.xml +++ /dev/null @@ -1,117 +0,0 @@ - - - - shopfloor menu tree - shopfloor.menu - - - - - - - - - - - - shopfloor menu form - shopfloor.menu - -
- - - -
-
-
- - shopfloor menu search - shopfloor.menu - - - - - - - - - - - - - - - - - Menus - shopfloor.menu - ir.actions.act_window - tree,form - -
diff --git a/shopfloor/views/shopfloor_profile_views.xml b/shopfloor/views/shopfloor_profile_views.xml deleted file mode 100644 index 1e2d9beee94..00000000000 --- a/shopfloor/views/shopfloor_profile_views.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - shopfloor.profile tree - shopfloor.profile - - - - - - - - shopfloor.profile form - shopfloor.profile - -
- - - - - - - - - - - -
-
-
- - shopfloor.profile search - shopfloor.profile - - - - - - - - - - Profiles - shopfloor.profile - ir.actions.act_window - tree,form - -
From 123c8b42460be66ab7b88499c20932ff42484e93 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 2 Feb 2021 16:08:08 +0100 Subject: [PATCH 530/710] shopfloor: refactor scan_anything w/ components --- shopfloor/__init__.py | 1 + shopfloor/actions/schema.py | 149 +++++++++++++++++ shopfloor/actions/schema_detail.py | 100 ++++++++++++ shopfloor/components/__init__.py | 5 + shopfloor/components/scan_handler_location.py | 26 +++ shopfloor/components/scan_handler_lot.py | 26 +++ shopfloor/components/scan_handler_package.py | 26 +++ shopfloor/components/scan_handler_product.py | 26 +++ shopfloor/components/scan_handler_transfer.py | 26 +++ shopfloor/services/__init__.py | 1 - shopfloor/services/scan_anything.py | 151 ------------------ shopfloor/tests/test_scan_anything.py | 32 +--- 12 files changed, 390 insertions(+), 179 deletions(-) create mode 100644 shopfloor/actions/schema.py create mode 100644 shopfloor/actions/schema_detail.py create mode 100644 shopfloor/components/__init__.py create mode 100644 shopfloor/components/scan_handler_location.py create mode 100644 shopfloor/components/scan_handler_lot.py create mode 100644 shopfloor/components/scan_handler_package.py create mode 100644 shopfloor/components/scan_handler_product.py create mode 100644 shopfloor/components/scan_handler_transfer.py delete mode 100644 shopfloor/services/scan_anything.py diff --git a/shopfloor/__init__.py b/shopfloor/__init__.py index 31f5a21b83d..436961449d7 100644 --- a/shopfloor/__init__.py +++ b/shopfloor/__init__.py @@ -1,3 +1,4 @@ from . import models from . import actions +from . import components from . import services diff --git a/shopfloor/actions/schema.py b/shopfloor/actions/schema.py new file mode 100644 index 00000000000..e09631841c0 --- /dev/null +++ b/shopfloor/actions/schema.py @@ -0,0 +1,149 @@ +# 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 ShopfloorSchemaAction(Component): + + _inherit = "shopfloor.schema.action" + + def picking(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "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": { + "type": "dict", + "nullable": True, + "required": True, + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + }, + }, + "scheduled_date": {"type": "string", "nullable": False, "required": True}, + } + + 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}, + "product": self._schema_dict_of(self.product()), + "lot": { + "type": "dict", + "required": False, + "nullable": True, + "schema": self.lot(), + }, + "package_src": self._schema_dict_of( + self.package(with_packaging=with_packaging) + ), + "package_dest": self._schema_dict_of( + self.package(with_packaging=with_packaging), required=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): + 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": 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()), + "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): + 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"}, + "storage_type": self._schema_dict_of(self._simple_record()), + } + if with_packaging: + schema["packaging"] = self._schema_dict_of(self.packaging()) + return schema + + 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): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "barcode": {"type": "string", "nullable": True, "required": False}, + } + + 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}, + } + + def picking_batch(self, with_pickings=False): + 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 { + "id": {"required": True, "type": "integer"}, + "is_done": {"type": "boolean", "nullable": False, "required": True}, + "picking": self._schema_dict_of(self._simple_record()), + "package_src": self._schema_dict_of(self.package()), + "location_src": self._schema_dict_of(self.location()), + "location_dest": self._schema_dict_of(self.location()), + "product": self._schema_dict_of(self.product()), + "quantity": {"type": "float", "required": True}, + } + + def picking_type(self): + return { + "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/actions/schema_detail.py b/shopfloor/actions/schema_detail.py new file mode 100644 index 00000000000..8a450c9390f --- /dev/null +++ b/shopfloor/actions/schema_detail.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 odoo.addons.component.core import Component + + +class ShopfloorSchemaDetailAction(Component): + _inherit = "shopfloor.schema.detail.action" + + 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( + { + "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()), + "move_lines": self._schema_list_of(self.move_line()), + } + ) + return schema + + def package_detail(self): + schema = self.package(with_packaging=True) + schema.update( + { + "pickings": self._schema_list_of(self.picking()), + "move_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/components/__init__.py b/shopfloor/components/__init__.py new file mode 100644 index 00000000000..dc9e3f1188c --- /dev/null +++ b/shopfloor/components/__init__.py @@ -0,0 +1,5 @@ +from . import scan_handler_location +from . import scan_handler_package +from . import scan_handler_product +from . import scan_handler_lot +from . import scan_handler_transfer diff --git a/shopfloor/components/scan_handler_location.py b/shopfloor/components/scan_handler_location.py new file mode 100644 index 00000000000..9124125e085 --- /dev/null +++ b/shopfloor/components/scan_handler_location.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (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 LocationHandler(Component): + """Scan anything handler for stock.location. + """ + + _name = "shopfloor.scan.location.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "location" + + def search(self, identifier): + return self._search.location_from_scan(identifier) + + @property + def converter(self): + return self._data_detail.location_detail + + def schema(self): + return self._schema_detail.location_detail() diff --git a/shopfloor/components/scan_handler_lot.py b/shopfloor/components/scan_handler_lot.py new file mode 100644 index 00000000000..8ee81c4b55a --- /dev/null +++ b/shopfloor/components/scan_handler_lot.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (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 LotHandler(Component): + """Scan anything handler for stock.production.lot. + """ + + _name = "shopfloor.scan.lot.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "lot" + + def search(self, identifier): + return self._search.lot_from_scan(identifier) + + @property + def converter(self): + return self._data_detail.lot_detail + + def schema(self): + return self._schema_detail.lot_detail() diff --git a/shopfloor/components/scan_handler_package.py b/shopfloor/components/scan_handler_package.py new file mode 100644 index 00000000000..eb692405911 --- /dev/null +++ b/shopfloor/components/scan_handler_package.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (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 PackageHandler(Component): + """Scan anything handler for stock.quant.package. + """ + + _name = "shopfloor.scan.package.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "package" + + def search(self, identifier): + return self._search.package_from_scan(identifier) + + @property + def converter(self): + return self._data_detail.package_detail + + def schema(self): + return self._schema_detail.package_detail() diff --git a/shopfloor/components/scan_handler_product.py b/shopfloor/components/scan_handler_product.py new file mode 100644 index 00000000000..1db3897b75d --- /dev/null +++ b/shopfloor/components/scan_handler_product.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (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 ProductHandler(Component): + """Scan anything handler for product.product. + """ + + _name = "shopfloor.scan.product.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "product" + + def search(self, identifier): + return self._search.product_from_scan(identifier) + + @property + def converter(self): + return self._data_detail.product_detail + + def schema(self): + return self._schema_detail.product_detail() diff --git a/shopfloor/components/scan_handler_transfer.py b/shopfloor/components/scan_handler_transfer.py new file mode 100644 index 00000000000..39b77cf0e91 --- /dev/null +++ b/shopfloor/components/scan_handler_transfer.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (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 TransferHandler(Component): + """Scan anything handler for stock.picking. + """ + + _name = "shopfloor.scan.transfer.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "transfer" + + def search(self, identifier): + return self._search.picking_from_scan(identifier) + + @property + def converter(self): + return self._data_detail.picking_detail + + def schema(self): + return self._schema_detail.picking_detail() diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 924fa1c5ae5..c299b657a61 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -3,7 +3,6 @@ # generic services from . import menu -from . import scan_anything # process services from . import checkout diff --git a/shopfloor/services/scan_anything.py b/shopfloor/services/scan_anything.py deleted file mode 100644 index a943fc5c654..00000000000 --- a/shopfloor/services/scan_anything.py +++ /dev/null @@ -1,151 +0,0 @@ -# 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 - - -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 = { - "body": _( - "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): - """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") - schema = self.component(usage="schema_detail") - return ( - ( - "location", - search.location_from_scan, - self.data_detail.location_detail, - schema.location_detail, - ), - ( - "package", - search.package_from_scan, - self.data_detail.package_detail, - schema.package_detail, - ), - ( - "product", - search.product_from_scan, - self.data_detail.product_detail, - schema.product_detail, - ), - ( - "lot", - search.lot_from_scan, - self.data_detail.lot_detail, - schema.lot_detail, - ), - ( - "transfer", - search.picking_from_scan, - self.data_detail.picking_detail, - schema.picking_detail, - ), - ) - - -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/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py index 7b2e9d4334a..300716dc475 100644 --- a/shopfloor/tests/test_scan_anything.py +++ b/shopfloor/tests/test_scan_anything.py @@ -1,33 +1,14 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.camptocamp.com) +# @author Simone Orsi # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from .test_actions_data_detail import ActionsDataDetailCaseBase +from odoo.addons.shopfloor_base.tests.common_misc import ScanAnythingTestMixin +from .test_actions_data_base import ActionsDataDetailCaseBase -class TestScanAnythingCase(ActionsDataDetailCaseBase): - def setUp(self): - super().setUp() - 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["body"]) - for rec_type in tried: - self.assertIn(rec_type, message["body"]) +class ScanAnythingCase(ActionsDataDetailCaseBase, ScanAnythingTestMixin): def test_scan_product(self): record = self.product_b record.barcode = "PROD-B" @@ -63,6 +44,3 @@ def test_scan_transfer(self): identifier = record.name data = self.data_detail.picking_detail(record) self._test_response_ok(rec_type, data, identifier) - - def test_scan_error(self): - self._test_response_ko("404-NOTFOUND") From bc713f1ad0fb63cfdbda4cd5011fc40a22de334f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 2 Feb 2021 17:22:47 +0100 Subject: [PATCH 531/710] shopfloor: remove harcoded conf for scenario Options can now be configured on scenario hence is useless to have hardcoded behavior on menu model. --- shopfloor/data/shopfloor_scenario_data.xml | 14 +++++++ shopfloor/models/shopfloor_menu.py | 43 ++++++---------------- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/shopfloor/data/shopfloor_scenario_data.xml b/shopfloor/data/shopfloor_scenario_data.xml index 3c13a0c098d..35c202eee21 100644 --- a/shopfloor/data/shopfloor_scenario_data.xml +++ b/shopfloor/data/shopfloor_scenario_data.xml @@ -2,6 +2,12 @@ Single Pack Transfer single_pack_transfer + +allow_create_moves: true, +allow_unreserve_other_moves: true, +allow_ignore_no_putaway_available: true, +must_move_entire_pack: true, + Zone Picking @@ -18,9 +24,17 @@ Delivery delivery + +must_move_entire_pack: true, + Location content transfer location_content_transfer + +allow_create_moves: true, +allow_unreserve_other_moves: true, +allow_ignore_no_putaway_available: true, + diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index e9dae5fd40e..caaf50212d8 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -6,23 +6,6 @@ class ShopfloorMenu(models.Model): _inherit = "shopfloor.menu" - # TODO: replace w/ options on shopfloor.scenario - # TODO: check if migration step is required for old `options` stored on menu - _scenario_allowing_create_moves = ( - "single_pack_transfer", - "location_content_transfer", - ) - - _scenario_allowing_unreserve_other_moves = ( - "single_pack_transfer", - "location_content_transfer", - ) - - _scenario_allowing_ignore_no_putaway_available = ( - "single_pack_transfer", - "location_content_transfer", - ) - picking_type_ids = fields.Many2many( comodel_name="stock.picking.type", string="Operation Types", required=True ) @@ -61,7 +44,7 @@ class ShopfloorMenu(models.Model): 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 + menu.scenario_id.has_option("allow_create_moves") and len(menu.picking_type_ids) == 1 ) @@ -77,22 +60,22 @@ def _check_allow_move_create(self): _("Creation of moves is not allowed for menu {}.").format(menu.name) ) - @api.depends("scenario_id", "picking_type_ids") + @api.depends("scenario_id") 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 + menu.unreserve_other_moves_is_possible = menu.scenario_id.has_option( + "allow_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.depends("scenario_id", "picking_type_ids") + @api.depends("scenario_id") 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 + menu.ignore_no_putaway_available_is_possible = menu.scenario_id.has_option( + "allow_ignore_no_putaway_available" ) @api.onchange("ignore_no_putaway_available_is_possible") @@ -125,13 +108,6 @@ def _check_allow_unreserve_other_moves(self): ).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", "delivery") - @api.constrains("scenario_id", "picking_type_ids") def _check_move_entire_packages(self): for menu in self: @@ -139,7 +115,10 @@ def _check_move_entire_packages(self): 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: + if ( + menu.scenario_id.has_option("must_move_entire_pack") + and bad_picking_types + ): scenario_name = menu.scenario_id.name raise exceptions.ValidationError( _( From 53d09d98fefbc03587d27aa1a9f39b6ce74794ef Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 11 Feb 2021 09:08:07 +0100 Subject: [PATCH 532/710] shopfloor: checkout disable_no_package compat w/ yaml options --- shopfloor/services/checkout.py | 4 ++-- shopfloor/tests/test_checkout_no_package.py | 2 +- shopfloor/tests/test_checkout_select_line.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 2cc84ee6810..8c1b4a79b9e 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -77,7 +77,7 @@ def _response_for_select_package(self, picking, lines, message=None): "picking": self.data.picking(picking), "packing_info": self._data_for_packing_info(picking), "no_package_enabled": not self.options.get( - "checkout:disable_no_package" + "checkout__disable_no_package" ), }, message=message, @@ -790,7 +790,7 @@ 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"): + 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) diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py index 07cb846c34a..acc289e40f8 100644 --- a/shopfloor/tests/test_checkout_no_package.py +++ b/shopfloor/tests/test_checkout_no_package.py @@ -65,7 +65,7 @@ def test_no_package_ok(self): ) def test_no_package_disabled(self): - self.service.work.options = {"checkout:disable_no_package": True} + self.service.work.options = {"checkout__disable_no_package": True} with self.assertRaises(werkzeug.exceptions.BadRequest) as err: self.service.dispatch( "no_package", diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py index 2218060026e..d82d6859a8e 100644 --- a/shopfloor/tests/test_checkout_select_line.py +++ b/shopfloor/tests/test_checkout_select_line.py @@ -33,7 +33,7 @@ def test_select_line_package_ok(self): 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} + self.service.work.options = {"checkout__disable_no_package": True} response = self.service.dispatch( "select_line", params={ From 82077097b4cb6864a49e3b4b50c46150bb152554 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Feb 2021 07:43:05 +0100 Subject: [PATCH 533/710] shopfloor: fix rebase on 13.0 --- shopfloor/__manifest__.py | 9 --- shopfloor/actions/schema_detail.py | 1 + shopfloor/tests/test_actions_data_detail.py | 63 --------------------- 3 files changed, 1 insertion(+), 72 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index a5ec72c51a5..a545d2b3d25 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -48,18 +48,9 @@ "data": [ "security/groups.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/menus.xml", - ], - "demo": [ - "demo/auth_api_key_demo.xml", - "demo/stock_picking_type_demo.xml", - "demo/shopfloor_menu_demo.xml", - "demo/shopfloor_profile_demo.xml", ], "demo": ["demo/stock_picking_type_demo.xml", "demo/shopfloor_menu_demo.xml"], } diff --git a/shopfloor/actions/schema_detail.py b/shopfloor/actions/schema_detail.py index 8a450c9390f..734d80ca40d 100644 --- a/shopfloor/actions/schema_detail.py +++ b/shopfloor/actions/schema_detail.py @@ -43,6 +43,7 @@ def package_detail(self): { "pickings": self._schema_list_of(self.picking()), "move_lines": self._schema_list_of(self.move_line()), + "location": self._schema_dict_of(self._simple_record()), } ) return schema diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index aa166ed2003..5154b2467da 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -15,69 +15,6 @@ def fake_colored_image(color="#4169E1", size=(800, 500)): return base64.b64encode(img_file.read()) -class ActionsDataDetailCaseBase(ActionsDataCaseBase): - @classmethod - 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" - ) - - def _expected_location_detail(self, record, **kw): - return dict( - **self._expected_location(record), - **{ - "complete_name": record.complete_name, - "reserved_move_lines": self.data_detail.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 TestActionsDataDetailCase(ActionsDataDetailCaseBase): def test_data_location(self): location = self.stock_location From 5af85f24f7b9254efe2842bf80434af96849d15d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Feb 2021 09:10:44 +0100 Subject: [PATCH 534/710] shopfloor: fix copyright --- shopfloor/__manifest__.py | 3 ++- shopfloor/components/scan_handler_location.py | 2 +- shopfloor/components/scan_handler_lot.py | 2 +- shopfloor/components/scan_handler_package.py | 2 +- shopfloor/components/scan_handler_product.py | 2 +- shopfloor/components/scan_handler_transfer.py | 2 +- .../migrations/13.0.4.0.0/pre-migration.py | 22 +++++++++++++++++++ shopfloor/tests/test_scan_anything.py | 2 +- 8 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 shopfloor/migrations/13.0.4.0.0/pre-migration.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index a545d2b3d25..97883480a98 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.3.1.0", + "version": "13.0.4.0.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", @@ -46,6 +46,7 @@ "product_packaging_type", ], "data": [ + "data/shopfloor_scenario_data.xml", "security/groups.xml", "security/ir.model.access.csv", "views/stock_picking_type.xml", diff --git a/shopfloor/components/scan_handler_location.py b/shopfloor/components/scan_handler_location.py index 9124125e085..dae8b354c2c 100644 --- a/shopfloor/components/scan_handler_location.py +++ b/shopfloor/components/scan_handler_location.py @@ -1,5 +1,5 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) -# Copyright 2021 ACSONE SA/NV (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) # @author Simone Orsi # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). diff --git a/shopfloor/components/scan_handler_lot.py b/shopfloor/components/scan_handler_lot.py index 8ee81c4b55a..95d816724f3 100644 --- a/shopfloor/components/scan_handler_lot.py +++ b/shopfloor/components/scan_handler_lot.py @@ -1,5 +1,5 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) -# Copyright 2021 ACSONE SA/NV (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) # @author Simone Orsi # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). diff --git a/shopfloor/components/scan_handler_package.py b/shopfloor/components/scan_handler_package.py index eb692405911..ee0eff762aa 100644 --- a/shopfloor/components/scan_handler_package.py +++ b/shopfloor/components/scan_handler_package.py @@ -1,5 +1,5 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) -# Copyright 2021 ACSONE SA/NV (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) # @author Simone Orsi # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). diff --git a/shopfloor/components/scan_handler_product.py b/shopfloor/components/scan_handler_product.py index 1db3897b75d..ac06a933edf 100644 --- a/shopfloor/components/scan_handler_product.py +++ b/shopfloor/components/scan_handler_product.py @@ -1,5 +1,5 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) -# Copyright 2021 ACSONE SA/NV (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) # @author Simone Orsi # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). diff --git a/shopfloor/components/scan_handler_transfer.py b/shopfloor/components/scan_handler_transfer.py index 39b77cf0e91..3fc26092221 100644 --- a/shopfloor/components/scan_handler_transfer.py +++ b/shopfloor/components/scan_handler_transfer.py @@ -1,5 +1,5 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) -# Copyright 2021 ACSONE SA/NV (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) # @author Simone Orsi # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). diff --git a/shopfloor/migrations/13.0.4.0.0/pre-migration.py b/shopfloor/migrations/13.0.4.0.0/pre-migration.py new file mode 100644 index 00000000000..47d56ccc283 --- /dev/null +++ b/shopfloor/migrations/13.0.4.0.0/pre-migration.py @@ -0,0 +1,22 @@ +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.tools import column_exists, table_exists + + +def migrate(cr, version): + # Scenario reference changed on split to shopfloor_base + if column_exists(cr, "shopfloor_menu", "scenario") and table_exists( + cr, "shopfloor_scenario" + ): + cr.execute( + """ + UPDATE + shopfloor_menu + SET + scenario_id = shopfloor_scenario.id + FROM + shopfloor_scenario + WHERE + shopfloor_menu.scenario = shopfloor_scenario.key + """ + ) diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py index 300716dc475..c8ae3c5504b 100644 --- a/shopfloor/tests/test_scan_anything.py +++ b/shopfloor/tests/test_scan_anything.py @@ -1,5 +1,5 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) -# Copyright 2021 ACSONE SA/NV (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) # @author Simone Orsi # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). From 700530b17613cd21152811ed3d737edbfabe15a5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Feb 2021 10:44:18 +0100 Subject: [PATCH 535/710] shopfloor_base: keep relation menu <-> scenario for existing records --- .../migrations/13.0.4.0.0/pre-migration.py | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 shopfloor/migrations/13.0.4.0.0/pre-migration.py diff --git a/shopfloor/migrations/13.0.4.0.0/pre-migration.py b/shopfloor/migrations/13.0.4.0.0/pre-migration.py deleted file mode 100644 index 47d56ccc283..00000000000 --- a/shopfloor/migrations/13.0.4.0.0/pre-migration.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo.tools import column_exists, table_exists - - -def migrate(cr, version): - # Scenario reference changed on split to shopfloor_base - if column_exists(cr, "shopfloor_menu", "scenario") and table_exists( - cr, "shopfloor_scenario" - ): - cr.execute( - """ - UPDATE - shopfloor_menu - SET - scenario_id = shopfloor_scenario.id - FROM - shopfloor_scenario - WHERE - shopfloor_menu.scenario = shopfloor_scenario.key - """ - ) From 34d6e5a71a8328dc6688a65e21fa8ae0a8e163c1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 15 Feb 2021 08:28:28 +0100 Subject: [PATCH 536/710] shopfloor: fix after rebase on 13.0 --- shopfloor/actions/schema.py | 11 +- shopfloor/actions/schema_detail.py | 1 - .../services/location_content_transfer.py | 4 +- shopfloor/services/schema.py | 189 ------------------ shopfloor/services/schema_detail.py | 108 ---------- 5 files changed, 4 insertions(+), 309 deletions(-) delete mode 100644 shopfloor/services/schema.py delete mode 100644 shopfloor/services/schema_detail.py diff --git a/shopfloor/actions/schema.py b/shopfloor/actions/schema.py index e09631841c0..b226daeb72e 100644 --- a/shopfloor/actions/schema.py +++ b/shopfloor/actions/schema.py @@ -15,15 +15,8 @@ def picking(self): "note": {"type": "string", "nullable": True, "required": False}, "move_line_count": {"type": "integer", "nullable": True, "required": True}, "weight": {"required": True, "nullable": True, "type": "float"}, - "partner": { - "type": "dict", - "nullable": True, - "required": True, - "schema": { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - }, - }, + "partner": self._schema_dict_of(self._simple_record()), + "carrier": self._schema_dict_of(self._simple_record(), required=False), "scheduled_date": {"type": "string", "nullable": False, "required": True}, } diff --git a/shopfloor/actions/schema_detail.py b/shopfloor/actions/schema_detail.py index 734d80ca40d..99b164651a7 100644 --- a/shopfloor/actions/schema_detail.py +++ b/shopfloor/actions/schema_detail.py @@ -31,7 +31,6 @@ def picking_detail(self): }, "priority": {"type": "string", "nullable": True, "required": False}, "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/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index d645e57b2b0..a5c1765c3b8 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -770,7 +770,7 @@ def postpone_package(self, location_id, package_level_id): move_lines = self._find_transfer_move_lines(location) if package_level.exists(): pickings = move_lines.mapped("picking_id") - sorter = self.actions_for("location_content_transfer.sorter") + sorter = self._actions_for("location_content_transfer.sorter") sorter.feed_pickings(pickings) package_levels = sorter.package_levels() package_level.shopfloor_postpone(move_lines, package_levels) @@ -789,7 +789,7 @@ def postpone_line(self, location_id, move_line_id): move_lines = self._find_transfer_move_lines(location) if move_line.exists(): pickings = move_lines.mapped("picking_id") - sorter = self.actions_for("location_content_transfer.sorter") + sorter = self._actions_for("location_content_transfer.sorter") sorter.feed_pickings(pickings) package_levels = sorter.package_levels() move_line.shopfloor_postpone(move_lines, package_levels) diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py deleted file mode 100644 index 7dda0d0243a..00000000000 --- a/shopfloor/services/schema.py +++ /dev/null @@ -1,189 +0,0 @@ -# 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 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 _schema_list_of(self, schema, **kw): - schema = { - "type": "list", - "nullable": True, - "required": True, - "schema": {"type": "dict", "schema": schema}, - } - schema.update(kw) - return schema - - 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 = { - "type": "dict", - "nullable": True, - "required": True, - "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": 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": self._schema_dict_of(self._simple_record()), - "carrier": self._schema_dict_of(self._simple_record(), required=False), - "scheduled_date": {"type": "string", "nullable": False, "required": True}, - } - - 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}, - "product": self._schema_dict_of(self.product()), - "lot": { - "type": "dict", - "required": False, - "nullable": True, - "schema": self.lot(), - }, - "package_src": self._schema_dict_of( - self.package(with_packaging=with_packaging) - ), - "package_dest": self._schema_dict_of( - self.package(with_packaging=with_packaging), required=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): - 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": 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()), - "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): - 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"}, - "storage_type": self._schema_dict_of(self._simple_record()), - } - if with_packaging: - schema["packaging"] = self._schema_dict_of(self.packaging()) - return schema - - 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): - return { - "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, - "barcode": {"type": "string", "nullable": True, "required": False}, - } - - 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}, - } - - def picking_batch(self, with_pickings=False): - 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 { - "id": {"required": True, "type": "integer"}, - "is_done": {"type": "boolean", "nullable": False, "required": True}, - "picking": self._schema_dict_of(self._simple_record()), - "package_src": self._schema_dict_of(self.package()), - "location_src": self._schema_dict_of(self.location()), - "location_dest": self._schema_dict_of(self.location()), - "product": self._schema_dict_of(self.product()), - "quantity": {"type": "float", "required": True}, - } - - def picking_type(self): - return { - "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/schema_detail.py b/shopfloor/services/schema_detail.py deleted file mode 100644 index e87a2179fb4..00000000000 --- a/shopfloor/services/schema_detail.py +++ /dev/null @@ -1,108 +0,0 @@ -# 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 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 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( - { - "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()), - "move_lines": self._schema_list_of(self.move_line()), - } - ) - return schema - - def package_detail(self): - schema = self.package(with_packaging=True) - schema.update( - { - "pickings": self._schema_list_of(self.picking()), - "move_lines": self._schema_list_of(self.move_line()), - "location": self._schema_dict_of(self._simple_record()), - } - ) - 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 From 6b24a4ac8dbc4b74e6cd283bb184e9568e6a3de3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 16 Feb 2021 08:22:50 +0100 Subject: [PATCH 537/710] shopfloor_base: pure json for scenario options edit --- shopfloor/data/shopfloor_scenario_data.xml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/shopfloor/data/shopfloor_scenario_data.xml b/shopfloor/data/shopfloor_scenario_data.xml index 35c202eee21..0a2370d160b 100644 --- a/shopfloor/data/shopfloor_scenario_data.xml +++ b/shopfloor/data/shopfloor_scenario_data.xml @@ -3,10 +3,12 @@ Single Pack Transfer single_pack_transfer -allow_create_moves: true, -allow_unreserve_other_moves: true, -allow_ignore_no_putaway_available: true, -must_move_entire_pack: true, +{ + "allow_create_moves": true, + "allow_unreserve_other_moves": true, + "allow_ignore_no_putaway_available": true, + "must_move_entire_pack": true +} @@ -24,17 +26,17 @@ must_move_entire_pack: true, Delivery delivery - -must_move_entire_pack: true, - + {"must_move_entire_pack": true} Location content transfer location_content_transfer -allow_create_moves: true, -allow_unreserve_other_moves: true, -allow_ignore_no_putaway_available: true, +{ + "allow_create_moves": true, + "allow_unreserve_other_moves": true, + "allow_ignore_no_putaway_available": true +} From b99a1bb4b19a44b00194fd96383716c3fde9eb50 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 16 Feb 2021 08:30:48 +0100 Subject: [PATCH 538/710] shopfloor: update README --- shopfloor/README.rst | 42 ------------- shopfloor/readme/CONFIGURE.rst | 38 ------------ shopfloor/static/description/index.html | 81 +++++++------------------ 3 files changed, 22 insertions(+), 139 deletions(-) delete mode 100644 shopfloor/readme/CONFIGURE.rst diff --git a/shopfloor/README.rst b/shopfloor/README.rst index b8a2a4657d7..ad3b32e1e90 100644 --- a/shopfloor/README.rst +++ b/shopfloor/README.rst @@ -50,48 +50,6 @@ A default front-end application is provided by ``shopfloor_mobile``. .. 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 ===== diff --git a/shopfloor/readme/CONFIGURE.rst b/shopfloor/readme/CONFIGURE.rst deleted file mode 100644 index 92102a8b422..00000000000 --- a/shopfloor/readme/CONFIGURE.rst +++ /dev/null @@ -1,38 +0,0 @@ -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. diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html index fc9823dfd8e..9dc7a72fc12 100644 --- a/shopfloor/static/description/index.html +++ b/shopfloor/static/description/index.html @@ -390,62 +390,25 @@

Shopfloor

Table of contents

-
-

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.

-
- -
-

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

+

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:

@@ -454,21 +417,21 @@

Usage

-

Known issues / Roadmap

+

Known issues / Roadmap

  • improve documentation
  • split out scenario components to their own modules
-

Changelog

+

Changelog

-

13.0.1.0.0

+

13.0.1.0.0

First official version.

-

Bug Tracker

+

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 @@ -476,9 +439,9 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Camptocamp
  • BCIM
  • @@ -486,7 +449,7 @@

    Authors

-

Contributors

+

Contributors

-

Design

+

Design

-

Other credits

+

Other credits

Financial support

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association

OCA, or the Odoo Community Association, is a nonprofit organization whose From 32c3257b9d06c43b8dad3e4b26ca2bc3727ca00c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 17 Feb 2021 17:09:33 +0100 Subject: [PATCH 539/710] shopfloor: fix missing menu view override --- shopfloor/__manifest__.py | 1 + shopfloor/views/shopfloor_menu.xml | 61 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 shopfloor/views/shopfloor_menu.xml diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 97883480a98..4294b76b06c 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -49,6 +49,7 @@ "data/shopfloor_scenario_data.xml", "security/groups.xml", "security/ir.model.access.csv", + "views/shopfloor_menu.xml", "views/stock_picking_type.xml", "views/stock_location.xml", "views/stock_move_line.xml", diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml new file mode 100644 index 00000000000..6167d6a6447 --- /dev/null +++ b/shopfloor/views/shopfloor_menu.xml @@ -0,0 +1,61 @@ + + + + shopfloor.menu + + + + + + + + + + + + + + + + + + + + + + + shopfloor.menu + + + + + + + + + shopfloor.menu + + + + + + + + From 3002d08b45127339781ecb16197c5acadf9fe994 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 17 Feb 2021 17:10:03 +0100 Subject: [PATCH 540/710] shopfloor: fix menu.active field already in base --- shopfloor/models/shopfloor_menu.py | 1 - 1 file changed, 1 deletion(-) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index caaf50212d8..90673dc4b27 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -38,7 +38,6 @@ class ShopfloorMenu(models.Model): "if the put-away can find a sublocation (when putaway destination " "is different from the operation type's destination).", ) - active = fields.Boolean(default=True) @api.depends("scenario_id", "picking_type_ids") def _compute_move_create_is_possible(self): From 4b016cb5226e3f0cb6307ce1af45eac331700764 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 18 Feb 2021 15:35:25 +0100 Subject: [PATCH 541/710] shopfloor: grant permission to stock users on update --- .../migrations/13.0.4.0.0/post-migration.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 shopfloor/migrations/13.0.4.0.0/post-migration.py diff --git a/shopfloor/migrations/13.0.4.0.0/post-migration.py b/shopfloor/migrations/13.0.4.0.0/post-migration.py new file mode 100644 index 00000000000..8af37fe7c7a --- /dev/null +++ b/shopfloor/migrations/13.0.4.0.0/post-migration.py @@ -0,0 +1,18 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + env = api.Environment(cr, SUPERUSER_ID, {}) + + _logger.info("Give Shopfloor rights to Stock users") + stock_user_group = env.ref("stock.group_stock_user") + stock_mngr_group = env.ref("stock.group_stock_manager") + stock_user_group.implied_ids += env.ref("shopfloor_base.group_shopfloor_user") + stock_mngr_group.implied_ids += env.ref("shopfloor_base.group_shopfloor_manager") From 9084aa39a1045a7b6652bd1f59fc3c6d3306ad9c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 19 Feb 2021 10:05:52 +0100 Subject: [PATCH 542/710] shopfloor: fix package level unlink https://github.com/odoo/odoo/commit/b33e72d0bf027fb2c789b1b9476f7edf1a40b0a6 changed the behavior of package level deletion on move lines. --- shopfloor/models/stock_package_level.py | 27 ++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py index 8dd4aea84a8..764b00af70d 100644 --- a/shopfloor/models/stock_package_level.py +++ b/shopfloor/models/stock_package_level.py @@ -7,8 +7,10 @@ class StockPackageLevel(models.Model): _name = "stock.package_level" _inherit = ["stock.package_level", "shopfloor.priority.postpone.mixin"] - def shallow_unlink(self): - """Unlink but keep the moves + def explode_package(self): + """Unlink but keep the moves. + + Original motivation: A package level has a relation to "move_ids" only when the package level was created first from the UI and it created @@ -26,11 +28,18 @@ def shallow_unlink(self): * 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() + Commit + + https://github.com/odoo/odoo/commit/b33e72d0bf027fb2c789b1b9476f7edf1a40b0a6 + + introduced the handling of pkg level deletion + which is doing what was done by this method. + + Moreover it has been fixed here https://github.com/odoo/odoo/pull/66517. + + Hence, we keep this method to unify the action of "exploding a package" + especially to avoid to refactor many places every time the core changes. + """ + # This will trigger the deletion of the pkg level + self.move_line_ids.result_package_id = False From bd3cc6fb164ca4a16a2119033340e6e445b6eea2 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 24 Feb 2021 11:06:24 +0100 Subject: [PATCH 543/710] shopfloor: improve _fill_stock_for_moves in tests --- shopfloor/tests/common.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index b80dc507d6f..d0de05d08af 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -167,8 +167,21 @@ def _update_qty_in_location( @classmethod def _fill_stock_for_moves( - cls, moves, in_package=False, in_lot=False, location=False + cls, moves, in_package=False, same_package=True, in_lot=False, location=False ): + """Satisfy stock for given moves. + + :param moves: stock.move recordset + :param in_package: stock.quant.package record or simple boolean + If a package record is given, it will be used as package. + If a boolean true is given, a new package will be created for each move. + :param same_package: + modify the behavior of `in_package` to use the same package for all moves. + :param in_lot: stock.production.lot record or simple boolean + If a lot record is given, it will be used as lot. + If a boolean true is given, a new lot will be created. + """ + product_packages = {} product_locations = {} package = None if in_package: @@ -180,6 +193,12 @@ def _fill_stock_for_moves( key = (move.product_id, location or move.location_id) product_locations.setdefault(key, 0) product_locations[key] += move.product_qty + if in_package: + if isinstance(in_package, models.BaseModel): + package = in_package + if not package or package and not same_package: + package = cls.env["stock.quant.package"].create({}) + product_packages[key] = package for (product, location), qty in product_locations.items(): lot = None if in_lot: From 9da48923345c9f120e806ba25ac329fd70b7a45e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 24 Feb 2021 11:07:09 +0100 Subject: [PATCH 544/710] shopfloor: fix stock.move.extract_and_action_done There's no need to call action assign on the brand new backorder because all lines at that point will be already assigned. --- shopfloor/models/stock_move.py | 4 +++- shopfloor/tests/test_zone_picking_base.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index 26f34fbc2bf..87bac89037b 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -88,7 +88,9 @@ def extract_and_action_done(self): moves_todo.move_line_ids.package_level_id.write( {"picking_id": new_picking.id} ) - new_picking.action_assign() + # NOTE: at this stage all the operations should be assigned already + # hence the new picking must be assigned already. + # DO NOT CALL `new_picking.action_assign` or you'll wipe qty_done. assert new_picking.state == "assigned" new_picking.action_done() return True diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 0c3872358e5..a4552109dc1 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -219,7 +219,10 @@ def setUpClassBaseData(cls, *args, **kwargs): lines=[(cls.product_b, 10), (cls.product_f, 10)] ) cls._fill_stock_for_moves( - picking5.move_lines, in_package=True, location=cls.zone_sublocation4 + picking5.move_lines, + in_package=True, + same_package=False, + location=cls.zone_sublocation4, ) # 2 products available in zone_sublocation5, but one is partially available cls.picking6 = picking6 = cls._create_picking( From a5e9ceff59616e2748685666ae821067a23427cb Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 9 Feb 2021 12:17:33 +0100 Subject: [PATCH 545/710] shopfloor: add dependency on stock_helper To remove the duplicate implementation of StockLocation.is_sublocation_of() --- shopfloor/__manifest__.py | 4 ++-- shopfloor/models/stock_location.py | 12 ------------ 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 4294b76b06c..534609a0779 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -24,11 +24,11 @@ "base_sparse_field", "auth_api_key", # OCA / stock-logistics-warehouse + "stock_helper", "stock_picking_completion_info", + "stock_quant_package_product_packaging", # OCA / stock-logistics-workflow "stock_quant_package_dimension", - # OCA / stock-logistics-warehouse - "stock_quant_package_product_packaging", # TODO: used for manuf info on prod detail. # This must be an optional dep "product_manufacturer", diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index 6b8c3cd921e..c7c5ee637a6 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -23,18 +23,6 @@ class StockLocation(models.Model): compute="_compute_reserved_move_lines", ) - 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 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( [ From c8db00fddc336f74896601b8473e77ac36bc87b4 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Wed, 24 Feb 2021 11:25:05 +0000 Subject: [PATCH 546/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 127 ++++------------------------------- 1 file changed, 13 insertions(+), 114 deletions(-) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 073ed7806a7..0c6471b5d99 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -13,12 +13,6 @@ msgstr "" "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 @@ -36,12 +30,6 @@ msgstr "" 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" @@ -63,14 +51,6 @@ msgstr "" 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 @@ -126,12 +106,14 @@ msgstr "" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_checkout +#: model:shopfloor.scenario,name:shopfloor.scenario_checkout #: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo msgid "Checkout" msgstr "" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_cluster_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_cluster_picking #: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo msgid "Cluster Picking" msgstr "" @@ -162,8 +144,6 @@ 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 "" @@ -177,8 +157,6 @@ 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 "" @@ -190,15 +168,14 @@ msgstr "" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery +#: model:shopfloor.scenario,name:shopfloor.scenario_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 "" @@ -215,16 +192,9 @@ msgstr "" msgid "From" msgstr "" -#. module: shopfloor -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view -msgid "Group By" -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 "" @@ -271,23 +241,17 @@ 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 "" @@ -327,6 +291,11 @@ msgstr "" msgid "Location Content Transfer" msgstr "" +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_location_content_transfer +msgid "Location content transfer" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -374,18 +343,6 @@ msgstr "" 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" @@ -408,12 +365,6 @@ msgstr "" 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 @@ -544,11 +495,6 @@ msgstr "" msgid "Operation's already running. Would you like to take it over?" msgstr "" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__options -msgid "Options" -msgstr "" - #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -731,18 +677,6 @@ msgstr "" msgid "Product(s) processed as raw product(s)" msgstr "" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__profile_id -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view -msgid "Profile" -msgstr "" - -#. module: shopfloor -#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_profile -#: 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" @@ -753,14 +687,6 @@ msgstr "" msgid "Real pack weight or the estimated one." 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 @@ -819,17 +745,6 @@ msgstr "" msgid "Scan the package" msgstr "" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scenario -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view -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 @@ -840,11 +755,6 @@ msgid "" "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 @@ -878,7 +788,6 @@ msgid "" msgstr "" #. module: shopfloor -#: model:ir.ui.menu,name:shopfloor.menu_shopfloor_settings #: 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" @@ -924,13 +833,13 @@ msgid "Shopfloor User" msgstr "" #. module: shopfloor -#: model:ir.model,name:shopfloor.model_shopfloor_profile -msgid "Shopfloor profile settings" +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Shopfloor weight (kg)" msgstr "" #. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight -msgid "Shopfloor weight (kg)" +#: model:shopfloor.scenario,name:shopfloor.scenario_single_pack_transfer +msgid "Single Pack Transfer" msgstr "" #. module: shopfloor @@ -1063,12 +972,6 @@ msgid "" "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 @@ -1213,11 +1116,6 @@ msgstr "" msgid "Unreserve Other Moves Is Possible" msgstr "" -#. module: shopfloor -#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_id -msgid "Visible on this profile only" -msgstr "" - #. module: shopfloor #: code:addons/shopfloor/services/cluster_picking.py:0 #, python-format @@ -1262,6 +1160,7 @@ msgstr "" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_zone_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_zone_picking #: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo msgid "Zone Picking" msgstr "" From cabac34562132b395293cc6a18d59bb9bc4c147f Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Wed, 24 Feb 2021 11:25:31 +0000 Subject: [PATCH 547/710] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/ --- shopfloor/i18n/es_AR.po | 186 +++++++++++++++------------------------- 1 file changed, 69 insertions(+), 117 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 0e43dfe5453..4ccb745fc58 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -16,12 +16,6 @@ msgstr "" "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 "%s actualizado." - #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -39,12 +33,6 @@ msgstr "Se ha creado un borrador de inventario para su control." msgid "Activate Zero Check" 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 "Activo" - #. module: shopfloor #: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin msgid "Adds shopfloor priority/postpone fields" @@ -66,14 +54,6 @@ msgstr "Permitir Creación de Movimiento" msgid "Allow to process reserved quantities" msgstr "Permitir procesar cantidades reservadas" -#. 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 "Archivado" - #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -129,12 +109,14 @@ msgstr "No se puede cambiar al lote {} ya que está completamente recogido." #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_checkout +#: model:shopfloor.scenario,name:shopfloor.scenario_checkout #: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo msgid "Checkout" msgstr "Checkout" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_cluster_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_cluster_picking #: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo msgid "Cluster Picking" msgstr "Grupo de Picking" @@ -165,8 +147,6 @@ 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 "Creado por" @@ -182,8 +162,6 @@ 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 "Creado el" @@ -195,15 +173,14 @@ msgstr "La creación de movimientos no está permitida para el menú {}." #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery +#: model:shopfloor.scenario,name:shopfloor.scenario_delivery #: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo msgid "Delivery" msgstr "Entrega" #. 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 "Mostrar Nombre" @@ -223,16 +200,9 @@ msgstr "" msgid "From" msgstr "Desde" -#. module: shopfloor -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view -msgid "Group By" -msgstr "Agrupar por" - #. 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 "ID" @@ -284,23 +254,17 @@ msgstr "Ubicaciones de Inventario" #. 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 "Ú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 "Ú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 "Última Actualización el" @@ -341,6 +305,11 @@ msgstr "La Ubicación %s no contiene ningún paquete." msgid "Location Content Transfer" msgstr "Transferencia de Contenido de Ubicación" +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_location_content_transfer +msgid "Location content transfer" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -388,18 +357,6 @@ msgstr "Lote: " msgid "Menu displayed in the scanner application" 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 "Menús" - -#. module: shopfloor -#: model:ir.model.fields,help:shopfloor.field_shopfloor_profile__menu_ids -msgid "Menus visible for this profile" -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" @@ -423,12 +380,6 @@ 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 "Nombre" - #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -564,11 +515,6 @@ msgstr "Operación ya procesada." msgid "Operation's already running. Would you like to take it over?" msgstr "La operación ya está en marcha. ¿Le gustaría hacerse cargo?" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__options -msgid "Options" -msgstr "Opciones" - #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -757,18 +703,6 @@ msgstr "Producto(s) empaquetado(s) en {}" msgid "Product(s) processed as raw product(s)" msgstr "Producto(s) procesado(s) como producto(s) crudo(s)" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__profile_id -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view -msgid "Profile" -msgstr "Perfil" - -#. module: shopfloor -#: model:ir.actions.act_window,name:shopfloor.action_shopfloor_profile -#: model:ir.ui.menu,name:shopfloor.menu_action_shopfloor_profile -msgid "Profiles" -msgstr "Perfiles" - #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_quant msgid "Quants" @@ -779,16 +713,6 @@ msgstr "Cantidades" msgid "Real pack weight or the estimated one." msgstr "Peso real del paquete o estimado." -#. 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 "" -"Registro no encontrado.\n" -"Hemos tratado con los siguientes tipos: {}" - #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -847,17 +771,6 @@ msgstr "Escanear la ubicación de destino" msgid "Scan the package" msgstr "Escanear el paquete" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scenario -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_search_view -msgid "Scenario" -msgstr "Escenario" - -#. module: shopfloor -#: model_terms:ir.ui.view,arch_db:shopfloor.shopfloor_menu_form_view -msgid "Scenario Options" -msgstr "Opciones de Escenario" - #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 #, python-format @@ -872,11 +785,6 @@ msgstr "" "{}.\n" "Por favor, ajuste su configuración." -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__sequence -msgid "Sequence" -msgstr "Secuencia" - #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -911,7 +819,6 @@ msgstr "" "transferencia manualmente." #. module: shopfloor -#: model:ir.ui.menu,name:shopfloor.menu_shopfloor_settings #: 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" @@ -956,16 +863,16 @@ msgstr "Taller Descargado" msgid "Shopfloor User" msgstr "Usuario del Taller" -#. module: shopfloor -#: model:ir.model,name:shopfloor.model_shopfloor_profile -msgid "Shopfloor profile settings" -msgstr "Ajustes del perfil de taller" - #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight msgid "Shopfloor weight (kg)" msgstr "Peso del taller (kg)" +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_single_pack_transfer +msgid "Single Pack Transfer" +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 @@ -1111,12 +1018,6 @@ msgstr "" "'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 "El registro %s %s no existe" - #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -1262,11 +1163,6 @@ msgstr "Error irrecuperable, por favor reinicie." msgid "Unreserve Other Moves Is Possible" msgstr "Es posible Anular la Reserva de Otros Movimientos" -#. module: shopfloor -#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__profile_id -msgid "Visible on this profile only" -msgstr "Visible en este perfil solo" - #. module: shopfloor #: code:addons/shopfloor/services/cluster_picking.py:0 #, python-format @@ -1311,6 +1207,7 @@ msgstr "Error de verificación cero en la ubicación {} ({})" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_zone_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_zone_picking #: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo msgid "Zone Picking" msgstr "Zona de Entreda" @@ -1331,6 +1228,61 @@ msgstr "" msgid "{} {} put in {}" msgstr "{} {} poner en {}" +#~ msgid "%s updated." +#~ msgstr "%s actualizado." + +#~ msgid "Active" +#~ msgstr "Activo" + +#~ msgid "Archived" +#~ msgstr "Archivado" + +#~ msgid "Group By" +#~ msgstr "Agrupar por" + +#~ msgid "Menus" +#~ msgstr "Menús" + +#~ msgid "Menus visible for this profile" +#~ msgstr "Menús visibles para este perfil" + +#~ msgid "Name" +#~ msgstr "Nombre" + +#~ msgid "Options" +#~ msgstr "Opciones" + +#~ msgid "Profile" +#~ msgstr "Perfil" + +#~ msgid "Profiles" +#~ msgstr "Perfiles" + +#~ msgid "" +#~ "Record not found.\n" +#~ "We've tried with the following types: {}" +#~ msgstr "" +#~ "Registro no encontrado.\n" +#~ "Hemos tratado con los siguientes tipos: {}" + +#~ msgid "Scenario" +#~ msgstr "Escenario" + +#~ msgid "Scenario Options" +#~ msgstr "Opciones de Escenario" + +#~ msgid "Sequence" +#~ msgstr "Secuencia" + +#~ msgid "Shopfloor profile settings" +#~ msgstr "Ajustes del perfil de taller" + +#~ msgid "The record %s %s does not exist" +#~ msgstr "El registro %s %s no existe" + +#~ msgid "Visible on this profile only" +#~ msgstr "Visible en este perfil solo" + #~ msgid "Auto-vacuum Shopfloor Logs" #~ msgstr "Eliminación Automática de Registros del Taller" From 67f50440e0beb492baedf979ef0157fe830f993e Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 24 Feb 2021 12:52:10 +0000 Subject: [PATCH 548/710] shopfloor 13.0.4.1.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 534609a0779..228d6ce8e87 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.4.0.0", + "version": "13.0.4.1.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 1addba16b9ca84fca78fb6a932cff92fdbfff584 Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Thu, 25 Feb 2021 04:32:11 +0000 Subject: [PATCH 549/710] Translated using Weblate (Spanish (Argentina)) Currently translated at 100.0% (195 of 195 strings) Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/es_AR/ --- shopfloor/i18n/es_AR.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 4ccb745fc58..27deb26fa11 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 13.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2021-02-16 21:45+0000\n" +"PO-Revision-Date: 2021-02-25 06:45+0000\n" "Last-Translator: Ignacio Buioli \n" "Language-Team: none\n" "Language: es_AR\n" @@ -308,7 +308,7 @@ msgstr "Transferencia de Contenido de Ubicación" #. module: shopfloor #: model:shopfloor.scenario,name:shopfloor.scenario_location_content_transfer msgid "Location content transfer" -msgstr "" +msgstr "Transferencia de contenido de ubicación" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -871,7 +871,7 @@ msgstr "Peso del taller (kg)" #. module: shopfloor #: model:shopfloor.scenario,name:shopfloor.scenario_single_pack_transfer msgid "Single Pack Transfer" -msgstr "" +msgstr "Transferencia de Paquete Individual" #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_single_pallet_transfer From 7ea3149e9bdd0e559682c5e4d63d19fd42b53aab Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 25 Feb 2021 14:31:24 +0100 Subject: [PATCH 550/710] shopfloor: search prevent query w/ no value + add tests --- shopfloor/actions/search.py | 34 +++++++++---- shopfloor/tests/test_actions_search.py | 70 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 shopfloor/tests/test_actions_search.py diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index 7a316051e70..9095d104cd1 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -17,20 +17,28 @@ class SearchAction(Component): # TODO: these methods shall be probably replaced by scan anything handlers def location_from_scan(self, barcode): + model = self.env["stock.location"] if not barcode: - return self.env["stock.location"].browse() - return self.env["stock.location"].search([("barcode", "=", barcode)], limit=1) + return model.browse() + return model.search([("barcode", "=", barcode)], limit=1) def package_from_scan(self, barcode): - return self.env["stock.quant.package"].search([("name", "=", barcode)], limit=1) + model = self.env["stock.quant.package"] + if not barcode: + return model.browse() + return model.search([("name", "=", barcode)], limit=1) def picking_from_scan(self, barcode): - return self.env["stock.picking"].search([("name", "=", barcode)], limit=1) + model = self.env["stock.picking"] + if not barcode: + return model.browse() + return model.search([("name", "=", barcode)], limit=1) def product_from_scan(self, barcode): - product = self.env["product.product"].search( - [("barcode", "=", barcode)], limit=1 - ) + model = self.env["product.product"] + if not barcode: + return model.browse() + product = model.search([("barcode", "=", barcode)], limit=1) if not product: packaging = self.env["product.packaging"].search( [("product_id", "!=", False), ("barcode", "=", barcode)], limit=1 @@ -39,11 +47,15 @@ def product_from_scan(self, barcode): return product def lot_from_scan(self, barcode): - return self.env["stock.production.lot"].search( - [("name", "=", barcode)], limit=1 - ) + model = self.env["stock.production.lot"] + if not barcode: + return model.browse() + return model.search([("name", "=", barcode)], limit=1) def generic_packaging_from_scan(self, barcode): - return self.env["product.packaging"].search( + model = self.env["product.packaging"] + if not barcode: + return model.browse() + return model.search( [("barcode", "=", barcode), ("product_id", "=", False)], limit=1 ) diff --git a/shopfloor/tests/test_actions_search.py b/shopfloor/tests/test_actions_search.py new file mode 100644 index 00000000000..b7cf8a67d07 --- /dev/null +++ b/shopfloor/tests/test_actions_search.py @@ -0,0 +1,70 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# @author Simone Orsi + +from .common import CommonCase + + +class TestSearchBaseCase(CommonCase): + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + with cls.work_on_actions(cls) as work: + cls.search = work.component(usage="search") + + +class TestSearchCase(TestSearchBaseCase): + def test_search_location(self): + rec = self.customer_location + handler = self.search.location_from_scan + self.assertEqual(handler(rec.barcode), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) + + def test_search_package(self): + rec = self.env["stock.quant.package"].sudo().create({"name": "1234"}) + handler = self.search.package_from_scan + self.assertEqual(handler(rec.name), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) + + def test_search_picking(self): + ptype = self.env.ref("shopfloor.picking_type_single_pallet_transfer_demo") + rec = self._create_picking(picking_type=ptype) + handler = self.search.picking_from_scan + self.assertEqual(handler(rec.name), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) + + def test_search_product(self): + rec = self.product_a + handler = self.search.product_from_scan + self.assertEqual(handler(rec.barcode), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) + packaging = self.product_a_packaging + self.assertEqual(handler(packaging.barcode), rec) + + def test_search_lot(self): + rec = ( + self.env["stock.production.lot"] + .sudo() + .create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + ) + handler = self.search.lot_from_scan + self.assertEqual(handler(rec.name), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) + + def test_search_generic_packaging(self): + rec = ( + self.env["product.packaging"] + .sudo() + .create({"name": "TEST PKG", "barcode": "1234"}) + ) + handler = self.search.generic_packaging_from_scan + self.assertEqual(handler(rec.barcode), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) From a7e8b22fcd5417b5d8a494fd627f719799213361 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 25 Feb 2021 14:33:09 +0100 Subject: [PATCH 551/710] shopfloor: search location by name too --- shopfloor/actions/search.py | 4 +++- shopfloor/tests/test_actions_search.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index 9095d104cd1..527dd62b862 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -20,7 +20,9 @@ def location_from_scan(self, barcode): model = self.env["stock.location"] if not barcode: return model.browse() - return model.search([("barcode", "=", barcode)], limit=1) + return model.search( + ["|", ("barcode", "=", barcode), ("name", "=", barcode)], limit=1 + ) def package_from_scan(self, barcode): model = self.env["stock.quant.package"] diff --git a/shopfloor/tests/test_actions_search.py b/shopfloor/tests/test_actions_search.py index b7cf8a67d07..516ca2c0d50 100644 --- a/shopfloor/tests/test_actions_search.py +++ b/shopfloor/tests/test_actions_search.py @@ -18,6 +18,7 @@ def test_search_location(self): rec = self.customer_location handler = self.search.location_from_scan self.assertEqual(handler(rec.barcode), rec) + self.assertEqual(handler(rec.name), rec) self.assertEqual(handler(False), rec.browse()) self.assertEqual(handler("NONE"), rec.browse()) From 865b727450a19512b6953285d693fc4f2c63af14 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 25 Feb 2021 14:34:06 +0100 Subject: [PATCH 552/710] shopfloor: location data barcode fallback to name --- shopfloor/actions/data.py | 7 ++++++- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_actions_data.py | 12 ++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 2b7a5bc2545..83aab964497 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -20,7 +20,12 @@ def locations(self, record, **kw): @property def _location_parser(self): - return ["id", "name", "barcode"] + return [ + "id", + "name", + # Fallback to name if barcode is not valued. + ("barcode", lambda rec, fname: rec[fname] if rec[fname] else rec.name), + ] @ensure_model("stock.picking") def picking(self, record, **kw): diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 4715a0bf2bd..add142a638a 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -3,6 +3,7 @@ from . import test_actions_change_package_lot from . import test_actions_data from . import test_actions_data_detail +from . import test_actions_search from . import test_single_pack_transfer from . import test_single_pack_transfer_putaway from . import test_cluster_picking_base diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 6759c2b6096..33c5c27cc81 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -21,6 +21,18 @@ def test_data_location(self): } self.assertDictEqual(data, expected) + def test_data_location_no_barcode(self): + location = self.stock_location + location.sudo().barcode = None + data = self.data.location(location) + self.assert_schema(self.schema.location(), data) + expected = { + "id": location.id, + "name": location.name, + "barcode": location.name, + } + self.assertDictEqual(data, expected) + def test_data_lot(self): lot = self.env["stock.production.lot"].create( { From 0430956d38d54e868301d4eb6c661f5520aa33d4 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 1 Mar 2021 15:39:34 +0000 Subject: [PATCH 553/710] shopfloor 13.0.4.2.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 228d6ce8e87..db4247fc2d4 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.4.1.0", + "version": "13.0.4.2.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From d6135110119351e1b1d9ce2b01baafa5cc0d3469 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Feb 2021 15:07:49 +0100 Subject: [PATCH 554/710] shopfloor: update descriptions --- shopfloor/readme/ROADMAP.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shopfloor/readme/ROADMAP.rst b/shopfloor/readme/ROADMAP.rst index 7f274ec28fa..e4e135eaa96 100644 --- a/shopfloor/readme/ROADMAP.rst +++ b/shopfloor/readme/ROADMAP.rst @@ -1,2 +1,4 @@ * improve documentation * split out scenario components to their own modules +* maybe split common stock features to `shopfloor_stock_base` + and move scenario to `shopfloor_wms`? From 09b763598ebe290759087a1d85ed23f94b2129ea Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 3 Mar 2021 09:56:15 +0100 Subject: [PATCH 555/710] shopfloor: drop dependency on rest_log --- shopfloor/__manifest__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index db4247fc2d4..89ac5aa1a00 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -20,7 +20,6 @@ "stock_picking_batch", "base_jsonify", "base_rest", - "rest_log", "base_sparse_field", "auth_api_key", # OCA / stock-logistics-warehouse From 152702aca3b37c04c627b9b537f532bdf90f7247 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 3 Mar 2021 09:56:49 +0100 Subject: [PATCH 556/710] shopfloor: fix inheritance of shopfloor.search.action --- shopfloor/actions/search.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py index 527dd62b862..26d17af44cd 100644 --- a/shopfloor/actions/search.py +++ b/shopfloor/actions/search.py @@ -10,9 +10,7 @@ class SearchAction(Component): have the same result in all scenarios. """ - _name = "shopfloor.search.action" - _inherit = "shopfloor.process.action" - _usage = "search" + _inherit = "shopfloor.search.action" # TODO: these methods shall be probably replaced by scan anything handlers From cd1a0bdfe4f2e1822fda01f01fae1ef45443a37f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 8 Mar 2021 14:43:40 +0100 Subject: [PATCH 557/710] shofloor: make menu picking type info not mandatory --- shopfloor/actions/schema.py | 16 ++++++++++++---- shopfloor/services/menu.py | 18 +++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/shopfloor/actions/schema.py b/shopfloor/actions/schema.py index b226daeb72e..55c0e2d6093 100644 --- a/shopfloor/actions/schema.py +++ b/shopfloor/actions/schema.py @@ -135,8 +135,16 @@ def picking_type(self): 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}, + "lines_count": {"type": "float", "required": False, "nullable": True}, + "picking_count": {"type": "float", "required": False, "nullable": True}, + "priority_lines_count": { + "type": "float", + "required": False, + "nullable": True, + }, + "priority_picking_count": { + "type": "float", + "required": False, + "nullable": True, + }, } diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index a0f672e190b..bac675bf632 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -9,8 +9,9 @@ class ShopfloorMenu(Component): def _convert_one_record(self, record): values = super()._convert_one_record(record) - counters = self._get_move_line_counters(record) - values.update(counters) + if record.picking_type_ids: + counters = self._get_move_line_counters(record) + values.update(counters) return values def _get_move_line_counters(self, record): @@ -24,8 +25,11 @@ def _get_move_line_counters(self, record): lines_per_menu = move_line_search.search_move_lines_by_location(locations) return move_line_search.counters_for_lines(lines_per_menu) - def _one_record_parser(self): - return super()._one_record_parser() + [ + def _one_record_parser(self, record): + parser = super()._one_record_parser(record) + if not record.picking_type_ids: + return parser + return parser + [ ("picking_type_ids:picking_types", ["id", "name"]), ] @@ -39,7 +43,11 @@ class ShopfloorMenuValidatorResponse(Component): def _record_schema(self): schema = super()._record_schema schema.update( - {"picking_types": self.schemas._schema_list_of(self._picking_type_schema)} + { + "picking_types": self.schemas._schema_list_of( + self._picking_type_schema, required=False, nullable=True + ) + } ) schema.update(self.schemas.move_lines_counters()) return schema From fbda2345be2012e501c891b464e7bf0a2a05b98c Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 8 Mar 2021 14:44:48 +0100 Subject: [PATCH 558/710] shopfloor: isolate menu items in tests --- shopfloor/tests/test_menu_base.py | 18 +++++++++++++++++- shopfloor/tests/test_menu_counters.py | 7 +++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/shopfloor/tests/test_menu_base.py b/shopfloor/tests/test_menu_base.py index 6c3fb86d496..59cbb05c673 100644 --- a/shopfloor/tests/test_menu_base.py +++ b/shopfloor/tests/test_menu_base.py @@ -10,7 +10,23 @@ class CommonMenuCase(CommonCase, MenuTestMixin): @classmethod def setUpClassVars(cls, *args, **kwargs): super().setUpClassVars(*args, **kwargs) - cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") + ref = cls.env.ref + profile1 = ref("shopfloor_base.profile_demo_1") + cls.profile = profile1.sudo().copy() + menu_xid_pref = "shopfloor.shopfloor_menu_" + cls.menu_items = ( + ref(menu_xid_pref + "single_pallet_transfer") + | ref(menu_xid_pref + "zone_picking") + | ref(menu_xid_pref + "cluster_picking") + | ref(menu_xid_pref + "checkout") + | ref(menu_xid_pref + "delivery") + | ref(menu_xid_pref + "location_content_transfer") + ) + # Isolate menu items + cls.menu_items.sudo().write({"profile_id": cls.profile.id}) + cls.env["shopfloor.menu"].search( + [("id", "not in", cls.menu_items.ids)] + ).sudo().write({"profile_id": profile1.id}) def setUp(self): super().setUp() diff --git a/shopfloor/tests/test_menu_counters.py b/shopfloor/tests/test_menu_counters.py index 8cec852e086..61de5416b20 100644 --- a/shopfloor/tests/test_menu_counters.py +++ b/shopfloor/tests/test_menu_counters.py @@ -20,5 +20,8 @@ def test_menu_search(self): }, } response = self.service.dispatch("search") - menus = self.env["shopfloor.menu"].search([]) - self._assert_menu_response(response, menus, expected_counters=expected_counters) + self._assert_menu_response( + response, + self.menu_items.sorted("sequence"), + expected_counters=expected_counters, + ) From f4921b002b163b67a339c0e94f241c69e1a370d1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 9 Mar 2021 15:55:31 +0100 Subject: [PATCH 559/710] shopfloor: bump 13.0.4.3.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 89ac5aa1a00..dae9506c8da 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.4.2.0", + "version": "13.0.4.3.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From df22afab797a1742b8fc4897479f58b47c64ccea Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 9 Mar 2021 15:26:36 +0000 Subject: [PATCH 560/710] [UPD] README.rst --- shopfloor/README.rst | 2 ++ shopfloor/static/description/index.html | 2 ++ 2 files changed, 4 insertions(+) diff --git a/shopfloor/README.rst b/shopfloor/README.rst index ad3b32e1e90..1f0a9b8a1f5 100644 --- a/shopfloor/README.rst +++ b/shopfloor/README.rst @@ -65,6 +65,8 @@ Known issues / Roadmap * improve documentation * split out scenario components to their own modules +* maybe split common stock features to `shopfloor_stock_base` + and move scenario to `shopfloor_wms`? Changelog ========= diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html index 9dc7a72fc12..45ee1c99e84 100644 --- a/shopfloor/static/description/index.html +++ b/shopfloor/static/description/index.html @@ -421,6 +421,8 @@

Known issues / Roadmap

  • improve documentation
  • split out scenario components to their own modules
  • +
  • maybe split common stock features to shopfloor_stock_base +and move scenario to shopfloor_wms?
From a7f2b061d654808f7a88be1e7b14ca5be3086ce2 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 9 Mar 2021 15:45:45 +0100 Subject: [PATCH 561/710] shopfloor: checkout fix list existing packages When listing available existing packages to select display only destination packages. --- shopfloor/services/checkout.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 8c1b4a79b9e..1e199346afc 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -92,9 +92,7 @@ def _data_for_packing_info(self, picking): 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" - ) + packages = picking.mapped("move_line_ids.result_package_id") if not packages: return self._response_for_select_package( picking, From b646d13894769c108d639bd01eadd839976d9eb0 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 11 Mar 2021 15:27:34 +0000 Subject: [PATCH 562/710] shopfloor 13.0.4.3.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index dae9506c8da..e1a1157fae4 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.4.3.0", + "version": "13.0.4.3.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From cc7ecbb0292d98d419ab75342fd0e20d2e37d95d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Mar 2021 08:33:39 +0100 Subject: [PATCH 563/710] shopfloor: delivery picking done only if moves are done Before this change the check for done qty was done only on move lines. Now the check is at move level, so that if there's any move not fully satisfied the picking is not set to done automatically and you have to confirm. --- shopfloor/models/stock_move.py | 10 ++++++++++ shopfloor/services/delivery.py | 29 ++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py index 87bac89037b..43bab66b062 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -1,11 +1,21 @@ # 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 +from odoo.tools.float_utils import float_compare class StockMove(models.Model): _inherit = "stock.move" + def _qty_is_satisfied(self): + compare = float_compare( + self.quantity_done, + self.product_uom_qty, + precision_rounding=self.product_uom.rounding, + ) + # greater or equal + return compare in (0, 1) + 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. diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index e965ec48c5a..44045d872cf 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -322,15 +322,30 @@ def _deliver_lot(self, picking, lot): ) 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. + def _action_picking_done(self, picking, force=False): + """Try to validate the stock picking if all quantities are satisfied. Return `True` if the picking has been validated successfully. + + :param picking: stock.picking recordset + :param force: bypass check and set picking as done no matter if satisfied. + You will likely get a backorder for not processed lines. """ - move_lines_done = all( - [line.qty_done >= line.product_uom_qty for line in picking.move_line_ids] - ) - if move_lines_done: + + if picking.state == "done": + return True + if force: + picking.action_done() + return True + all_done = False + for move in picking.move_lines: + if move.state in ("done", "cancel"): + continue + all_done = move._qty_is_satisfied() + if not all_done: + # At least one move not satisfied, cannot mark as done automatically + break + if all_done: picking.action_done() return True return False @@ -513,7 +528,7 @@ def done(self, picking_id, confirm=False): return self._response_for_deliver( message=self.msg_store.transfer_no_qty_done() ) - picking.action_done() + self._action_picking_done(picking, force=True) return self._response_for_deliver( message=self.msg_store.transfer_complete(picking) ) From c47b275fc168e7554730d390441c37a8173064f6 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 12 Mar 2021 09:11:46 +0000 Subject: [PATCH 564/710] shopfloor 13.0.4.3.2 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index e1a1157fae4..56d845ead9c 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.4.3.1", + "version": "13.0.4.3.2", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 862826606444215d8d31e4575e90596c6faefa1e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Fri, 12 Mar 2021 13:31:17 +0100 Subject: [PATCH 565/710] shopfloor: single pack transf. ignore draft pkg level When scanning a package, the list of related package levels are found. This list can include records from draft picking that were left behind which still reference the same package. If this happens we don't care about them. --- shopfloor/services/single_pack_transfer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 97d50216152..5cb696de37b 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -132,7 +132,7 @@ def start(self, barcode, confirmation=False): # 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") + lambda pl: pl.state not in ("cancel", "done", "draft") ) message = self.msg_store.no_pending_operation_for_pack(package) if not package_level and self.work.menu.allow_move_create: From 04144742e6960f1a811b69175bfac33f64403c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 8 Mar 2021 16:03:10 +0100 Subject: [PATCH 566/710] shopfloor: checkout, allows delivery packages only A delivery package is a package with a delivery packaging defined ('packaging_id' field). It is a package different than internal ones (which are stored in the warehouse with their own constraints like height...). Here we enforce the use of such delivery packages when we perform a checkout, this means that we can not use internal packages to perform a delivery. * 'list_dest_package' method ('/select_dest_package') is now listing only delivery packages * 'scan_dest_package' and 'set_dest_package' methods accept only a delivery package * 'scan_package_action' method accept only a delivery package --- shopfloor/actions/message.py | 14 +++- shopfloor/services/checkout.py | 79 +++++++++++++------ shopfloor/tests/test_checkout_list_package.py | 76 +++++++++++------- .../test_checkout_scan_package_action.py | 38 +++++---- 4 files changed, 136 insertions(+), 71 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 2968a312912..c14177ee46a 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -499,7 +499,19 @@ def package_open(self): def packaging_invalid_for_carrier(self, packaging, carrier): return { "message_type": "error", - "body": _("Packaging {} does not match carrier {}.").format( + "body": _("Packaging {} is not allowed for carrier {}.").format( packaging.name, carrier.name ), } + + def dest_package_not_valid(self, package): + return { + "message_type": "error", + "body": _("{} is not a valid destination package.").format(package.name), + } + + def no_valid_package_to_select(self): + return { + "message_type": "warning", + "body": _("No valid package to select."), + } diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 1e199346afc..0d4682e4e93 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -92,15 +92,17 @@ def _data_for_packing_info(self, picking): return "" def _response_for_select_dest_package(self, picking, move_lines, message=None): - packages = picking.mapped("move_line_ids.result_package_id") + packages = picking.mapped("move_line_ids.result_package_id").filtered("packaging_id") if not packages: + # FIXME: do we want to move from 'select_dest_package' to + # 'select_package' state? Until now (before enforcing the use of + # delivery package) this part of code was never reached as we + # always had a package on the picking (source or result) + # Also the response validator did not support this state... return self._response_for_select_package( picking, move_lines, - message={ - "message_type": "warning", - "body": _("No valid package to select."), - }, + message=self.msg_store.no_valid_package_to_select(), ) picking_data = self.data.picking(picking) packages_data = self.data.packages( @@ -619,26 +621,30 @@ 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") + """Check if a package is allowed as a destination/delivery package. + + A package is allowed as a destination one if it is present among + `picking` lines and qualified as a "delivery package" (having a + delivery packaging set on it). + """ + existing_packages = picking.mapped("move_line_ids.result_package_id").filtered( + "packaging_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 - Note: only packages which are already a destination package for another + Note: only packages which are already a delivery 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. + source packages are allowed too only if it is a delivery package (we + keep the current package). """ if not self._is_package_allowed(picking, package): return self._response_for_select_package( picking, selected_lines, - message={ - "message_type": "error", - "body": _("Not a valid destination package").format(package.name), - }, + message=self.msg_store.dest_package_not_valid(package), ) return self._put_lines_in_allowed_package(picking, selected_lines, package) @@ -678,10 +684,10 @@ def _create_and_assign_new_packaging(self, picking, selected_lines, packaging=No 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 - lines to pack. + When a package is scanned (only delivery ones), 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 the 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 @@ -732,6 +738,12 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): package = search.package_from_scan(barcode) if package: + if not package.packaging_id: + return self._response_for_select_package( + picking, + selected_lines, + message=self.msg_store.dest_package_not_valid(package), + ) return self._put_lines_in_package(picking, selected_lines, package) # Scan delivery packaging @@ -824,16 +836,11 @@ def list_dest_package(self, picking_id, selected_line_ids): return self._response_for_select_dest_package(picking, lines) 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", - "body": _("Not a valid destination package").format(package.name), - }, + message=self.msg_store.dest_package_not_valid(package), ) return self._put_lines_in_allowed_package(picking, selected_lines, package) @@ -861,6 +868,12 @@ def scan_dest_package(self, picking_id, selected_line_ids, barcode): lines = self.env["stock.move.line"].browse(selected_line_ids).exists() search = self._actions_for("search") package = search.package_from_scan(barcode) + if not package: + return self._response_for_select_dest_package( + picking, + lines, + message=self.msg_store.package_not_found_for_barcode(barcode), + ) return self._set_dest_package_from_selection(picking, lines, package) def set_dest_package(self, picking_id, selected_line_ids, package_id): @@ -881,6 +894,10 @@ def set_dest_package(self, picking_id, selected_line_ids, package_id): 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() + if not package: + return self._response_for_select_dest_package( + picking, lines, message=self.msg_store.record_not_found(), + ) return self._set_dest_package_from_selection(picking, lines, package) def summary(self, picking_id): @@ -1349,12 +1366,22 @@ def list_dest_package(self): def scan_dest_package(self): return self._response_schema( - next_states={"select_dest_package", "select_line", "summary"} + next_states={ + "select_dest_package", + "select_package", + "select_line", + "summary", + } ) def set_dest_package(self): return self._response_schema( - next_states={"select_dest_package", "select_line", "summary"} + next_states={ + "select_dest_package", + "select_package", + "select_line", + "summary", + } ) def summary(self): diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index 956f7eabed2..81ea35ceac6 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -52,11 +52,13 @@ def test_list_dest_package_ok(self): 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 - + delivery_packaging = self.env.ref( + "stock_storage_type.product_product_9_packaging_single_bag" + ) + delivery_package = self.env["stock.quant.package"].create( + {"packaging_id": delivery_packaging.id} + ) + picking.move_lines[1].move_line_ids.result_package_id = delivery_package response = self.service.dispatch( "list_dest_package", params={ @@ -65,7 +67,7 @@ def test_list_dest_package_ok(self): }, ) self._assert_response_select_dest_package( - response, picking, picking.move_line_ids, packages + response, picking, picking.move_line_ids, delivery_package ) def test_list_dest_package_error_no_package(self): @@ -115,29 +117,42 @@ def setUpClassBaseData(cls): 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.delivery_packaging = cls.env.ref( + "stock_storage_type.product_product_9_packaging_single_bag" + ) + cls.delivery_package = cls.env["stock.quant.package"].create( + {"packaging_id": cls.delivery_packaging.id} + ) cls.move_line1, cls.move_line2, cls.move_line3 = cls.selected_lines + # The 'scan_dest_package' and 'set_dest_package' methods can not be + # used at all if there is no valid delivery package on the picking + # (the user is redirected to the 'select_package' step in that case), + # so we need at least to set one to pass this check in order to test + # them + cls.move_line1.result_package_id = cls.delivery_package # 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 _get_allowed_packages(self, picking): + return ( + picking.mapped("move_line_ids.package_id") + | picking.mapped("move_line_ids.result_package_id") + ).filtered("packaging_id") def _assert_package_set(self, response): self.assertRecordValues( self.move_line1 + self.move_line2 + self.move_line3, [ { - "result_package_id": self.package.id, + "result_package_id": self.delivery_package.id, "shopfloor_checkout_done": True, }, { - "result_package_id": self.package.id, + "result_package_id": self.delivery_package.id, "shopfloor_checkout_done": True, }, # qty_done was zero so we don't set it as packed @@ -151,7 +166,7 @@ def _assert_package_set(self, response): data={"picking": self._stock_picking_data(self.picking)}, message={ "message_type": "success", - "body": "Product(s) packed in {}".format(self.pack1.name), + "body": "Product(s) packed in {}".format(self.delivery_package.name), }, ) @@ -162,22 +177,27 @@ def test_scan_dest_package_ok(self): "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, + "barcode": self.delivery_package.name, }, ) self._assert_package_set(response) def test_scan_dest_package_error_not_found(self): + barcode = "NO BARCODE" response = self.service.dispatch( "scan_dest_package", params={ "picking_id": self.picking.id, "selected_line_ids": self.selected_lines.ids, - "barcode": "NO BARCODE", + "barcode": barcode, }, ) self._assert_response_select_dest_package( - response, self.picking, self.selected_lines, self.allowed_packages + response, + self.picking, + self.selected_lines, + self._get_allowed_packages(self.picking), + message=self.service.msg_store.package_not_found_for_barcode(barcode), ) def test_scan_dest_package_error_not_allowed(self): @@ -194,11 +214,8 @@ def test_scan_dest_package_error_not_allowed(self): response, self.picking, self.selected_lines, - self.allowed_packages, - message={ - "message_type": "error", - "body": "Not a valid destination package", - }, + self._get_allowed_packages(self.picking), + message=self.service.msg_store.dest_package_not_valid(package), ) def test_set_dest_package_ok(self): @@ -207,7 +224,7 @@ def test_set_dest_package_ok(self): params={ "picking_id": self.picking.id, "selected_line_ids": self.selected_lines.ids, - "package_id": self.package.id, + "package_id": self.delivery_package.id, }, ) self._assert_package_set(response) @@ -222,7 +239,11 @@ def test_set_dest_package_error_not_found(self): }, ) self._assert_response_select_dest_package( - response, self.picking, self.selected_lines, self.allowed_packages + response, + self.picking, + self.selected_lines, + self._get_allowed_packages(self.picking), + message=self.service.msg_store.record_not_found(), ) def test_set_dest_package_error_not_allowed(self): @@ -239,9 +260,6 @@ def test_set_dest_package_error_not_allowed(self): response, self.picking, self.selected_lines, - self.allowed_packages, - message={ - "message_type": "error", - "body": "Not a valid destination package", - }, + self._get_allowed_packages(self.picking), + message=self.service.msg_store.dest_package_not_valid(package), ) diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index 3592649589d..c8b5f014f24 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -115,7 +115,7 @@ def test_scan_package_action_scan_product_error_tracking(self): tracking, barcode ) - def test_scan_package_action_scan_package_keep_source_package_ok(self): + def test_scan_package_action_scan_package_keep_source_package_error(self): picking = self._create_picking( lines=[ (self.product_a, 10), @@ -145,33 +145,39 @@ def test_scan_package_action_scan_package_keep_source_package_ok(self): 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 + # we try to keep the goods in the same package, so we scan the + # source package but this isn't allowed as it is not a delivery + # package (i.e. having a delivery packaging set) "barcode": pack1.name, }, ) self.assertRecordValues( move_line1, - [{"result_package_id": pack1.id, "shopfloor_checkout_done": True}], + [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}], ) self.assertRecordValues( move_line2, - [{"result_package_id": pack1.id, "shopfloor_checkout_done": True}], + [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}], ) self.assertRecordValues( move_line3, - # qty_done was zero so we don't set it as packed + # qty_done was zero so it hasn't been done anyway [{"result_package_id": pack1.id, "shopfloor_checkout_done": 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": "success", - "body": "Product(s) packed in {}".format(pack1.name), + next_state="select_package", + data={ + "picking": self.data.picking(picking), + "selected_move_lines": self.data.move_lines(selected_lines), + "packing_info": self.service._data_for_packing_info(picking), + "no_package_enabled": not self.service.options.get( + "checkout__disable_no_package" + ), }, + message=self.service.msg_store.dest_package_not_valid(pack1), ) def test_scan_package_action_scan_package_error_invalid(self): @@ -206,10 +212,7 @@ def test_scan_package_action_scan_package_error_invalid(self): self._assert_selected_response( response, selected_line, - message={ - "message_type": "error", - "body": "Not a valid destination package", - }, + message=self.service.msg_store.dest_package_not_valid(other_package), ) def test_scan_package_action_scan_package_use_existing_package_ok(self): @@ -228,7 +231,12 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self): self._fill_stock_for_moves(pack2_moves, in_package=True) picking.action_assign() - package = self.env["stock.quant.package"].create({}) + delivery_packaging = self.env.ref( + "stock_storage_type.product_product_9_packaging_single_bag" + ) + package = self.env["stock.quant.package"].create( + {"packaging_id": delivery_packaging.id} + ) # assume that product d was already put in a package, # we must be able to put the lines of pack1 inside the same From bb461fb648e1c26b15deaf41f5fd2c65ee772bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 10 Mar 2021 13:48:02 +0100 Subject: [PATCH 567/710] shopfloor: checkout, select the delivery packaging Add a new state/screen when the user click on the "New package" button to list available delivery packaging ('select_delivery_packaging' state) for the current transfer and select one to create the package (existing 'new_package' method which now accepts a packaging as parameter). --- shopfloor/actions/data.py | 19 +++ shopfloor/actions/message.py | 6 + shopfloor/actions/schema.py | 8 ++ shopfloor/services/checkout.py | 87 ++++++++++++- shopfloor/tests/__init__.py | 1 + shopfloor/tests/models.py | 22 ++++ shopfloor/tests/test_actions_data.py | 7 + shopfloor/tests/test_actions_data_base.py | 21 +++ .../test_checkout_list_delivery_packaging.py | 120 ++++++++++++++++++ 9 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 shopfloor/tests/models.py create mode 100644 shopfloor/tests/test_checkout_list_delivery_packaging.py diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 83aab964497..a68822f0448 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -100,6 +100,25 @@ def _packaging_parser(self): "qty", ] + @ensure_model("product.packaging") + def delivery_packaging(self, record, **kw): + return self._jsonify(record, self._delivery_packaging_parser, **kw) + + def delivery_packaging_list(self, records, **kw): + return self.delivery_packaging(records, multi=True) + + @property + def _delivery_packaging_parser(self): + return [ + "id", + "name", + ( + "packaging_type_id:packaging_type", + lambda rec, fname: rec.packaging_type_id.display_name, + ), + "barcode", + ] + @ensure_model("stock.production.lot") def lot(self, record, **kw): return self._jsonify(record, self._lot_parser, **kw) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index c14177ee46a..a1f523b534f 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -515,3 +515,9 @@ def no_valid_package_to_select(self): "message_type": "warning", "body": _("No valid package to select."), } + + def no_delivery_packaging_available(self): + return { + "message_type": "warning", + "body": _("No delivery package type available."), + } diff --git a/shopfloor/actions/schema.py b/shopfloor/actions/schema.py index 55c0e2d6093..84cd7fe32cd 100644 --- a/shopfloor/actions/schema.py +++ b/shopfloor/actions/schema.py @@ -103,6 +103,14 @@ def packaging(self): "qty": {"type": "float", "required": True}, } + def delivery_packaging(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "packaging_type": {"type": "string", "nullable": True, "required": True}, + "barcode": {"type": "string", "nullable": True, "required": True}, + } + def picking_batch(self, with_pickings=False): schema = { "id": {"required": True, "type": "integer"}, diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 0d4682e4e93..49df73cf58a 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -92,7 +92,9 @@ def _data_for_packing_info(self, picking): return "" def _response_for_select_dest_package(self, picking, move_lines, message=None): - packages = picking.mapped("move_line_ids.result_package_id").filtered("packaging_id") + packages = picking.mapped("move_line_ids.result_package_id").filtered( + "packaging_id" + ) if not packages: # FIXME: do we want to move from 'select_dest_package' to # 'select_package' state? Until now (before enforcing the use of @@ -120,6 +122,19 @@ def _response_for_select_dest_package(self, picking, move_lines, message=None): message=message, ) + def _response_for_select_delivery_packaging(self, picking, packaging, message=None): + return self._response( + next_state="select_delivery_packaging", + data={ + # We don't need to send the 'picking' as the mobile frontend + # already has this info after `select_document` state + # TODO adapt other endpoints to see if we can get rid of the + # 'picking' data + "packaging": self._data_for_delivery_packaging(packaging), + }, + message=message, + ) + def _response_for_change_packaging(self, picking, package, packaging_list): if not package: return self._response_for_summary( @@ -234,6 +249,9 @@ def _select_picking(self, picking, state_for_error): def _data_for_move_lines(self, lines, **kw): return self.data.move_lines(lines, **kw) + def _data_for_delivery_packaging(self, packaging, **kw): + return self.data.delivery_packaging_list(packaging, **kw) + def _data_for_stock_picking(self, picking, done=False): data = self.data.picking(picking) line_picker = self._lines_checkout_done if done else self._lines_to_pack @@ -770,7 +788,42 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): def _packaging_good_for_carrier(self, packaging, carrier): return packaging.package_carrier_type in ("none", carrier.delivery_type) - def new_package(self, picking_id, selected_line_ids): + def _get_available_delivery_packaging(self, picking): + return self.env["product.packaging"].search( + [ + ("product_id", "=", False), + ( + "package_carrier_type", + "=", + picking.carrier_id.delivery_type or "none", + ), + ], + order="name", + ) + + def list_delivery_packaging(self, picking_id, selected_line_ids): + """List available delivery packaging for given picking. + + Transitions: + * select_delivery_packaging: list available delivery packaging, the + user has to choose one to create the new package + * select_package: when no delivery packaging is available + """ + picking = self.env["stock.picking"].browse(picking_id) + 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() + delivery_packaging = self._get_available_delivery_packaging(picking) + if not delivery_packaging: + return self._response_for_select_package( + picking, + selected_lines, + message=self.msg_store.no_delivery_packaging_available(), + ) + return self._response_for_select_delivery_packaging(picking, delivery_packaging) + + def new_package(self, picking_id, selected_line_ids, packaging_id=None): """Add all selected lines in a new package It creates a new package and set it as the destination package of all @@ -787,8 +840,11 @@ def new_package(self, picking_id, selected_line_ids): message = self._check_picking_status(picking) if message: return self._response_for_select_document(message=message) + packaging = None + if packaging_id: + packaging = self.env["product.packaging"].browse(packaging_id).exists() selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() - return self._create_and_assign_new_packaging(picking, selected_lines) + return self._create_and_assign_new_packaging(picking, selected_lines, packaging) def no_package(self, picking_id, selected_line_ids): """Process all selected lines without any package. @@ -1121,6 +1177,16 @@ def scan_package_action(self): "barcode": {"required": True, "type": "string"}, } + def list_delivery_packaging(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"}, + }, + } + def new_package(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, @@ -1129,6 +1195,7 @@ def new_package(self): "required": True, "schema": {"coerce": to_int, "required": True, "type": "integer"}, }, + "packaging_id": {"coerce": to_int, "required": False, "type": "integer"}, } def no_package(self): @@ -1237,6 +1304,7 @@ def _states(self): ), "change_quantity": self._schema_selected_lines, "select_dest_package": self._schema_select_package, + "select_delivery_packaging": self._schema_select_delivery_packaging, "summary": self._schema_summary, "change_packaging": self._schema_select_packaging, "confirm_done": self._schema_confirm_done, @@ -1294,6 +1362,14 @@ def _schema_select_package(self): "picking": {"type": "dict", "schema": self.schemas.picking()}, } + @property + def _schema_select_delivery_packaging(self): + return { + "packaging": self.schemas._schema_list_of( + self.schemas.delivery_packaging() + ), + } + @property def _schema_select_packaging(self): return { @@ -1353,6 +1429,11 @@ def scan_package_action(self): next_states={"select_package", "select_line", "summary"} ) + def list_delivery_packaging(self): + return self._response_schema( + next_states={"select_delivery_packaging", "select_package"} + ) + def new_package(self): return self._response_schema(next_states={"select_line", "summary"}) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index add142a638a..ffe883632e5 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -27,6 +27,7 @@ from . import test_checkout_scan_package_action from . import test_checkout_new_package from . import test_checkout_no_package +from . import test_checkout_list_delivery_packaging from . import test_checkout_list_package from . import test_checkout_summary from . import test_checkout_change_packaging diff --git a/shopfloor/tests/models.py b/shopfloor/tests/models.py new file mode 100644 index 00000000000..7fef2079312 --- /dev/null +++ b/shopfloor/tests/models.py @@ -0,0 +1,22 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Test data models to get a mapping between carrier and delivery packaging. + +Add a new "test" value for 'delivery_type' field of carrier and +'package_carrier_type' field of packaging for test purpose because +Shopfloor do not depend on 'delivery_*' modules adding the different +delivery types. +""" +from odoo import fields, models + + +class DeliveryCarrierTest(models.Model): + _inherit = "delivery.carrier" + + delivery_type = fields.Selection(selection_add=[("test", "TEST")]) + + +class ProductPackagingTest(models.Model): + _inherit = "product.packaging" + + package_carrier_type = fields.Selection(selection_add=[("test", "TEST")]) diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 33c5c27cc81..7c60ed8f11f 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -10,6 +10,13 @@ def test_data_packaging(self): self.assert_schema(self.schema.packaging(), data) self.assertDictEqual(data, self._expected_packaging(self.packaging)) + def test_data_delivery_packaging(self): + data = self.data.delivery_packaging(self.delivery_packaging) + self.assert_schema(self.schema.delivery_packaging(), data) + self.assertDictEqual( + data, self._expected_delivery_packaging(self.delivery_packaging) + ) + def test_data_location(self): location = self.stock_location data = self.data.location(location) diff --git a/shopfloor/tests/test_actions_data_base.py b/shopfloor/tests/test_actions_data_base.py index fd33d53fe93..f1312ec30ae 100644 --- a/shopfloor/tests/test_actions_data_base.py +++ b/shopfloor/tests/test_actions_data_base.py @@ -30,6 +30,17 @@ def setUpClassBaseData(cls): .sudo() .create({"name": "Pallet", "packaging_type_id": cls.packaging_type.id}) ) + cls.delivery_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Pallet", + "packaging_type_id": cls.packaging_type.id, + "barcode": "PALCODE", + } + ) + ) cls.product_b.tracking = "lot" cls.product_c.tracking = "lot" cls.picking = cls._create_picking( @@ -140,6 +151,16 @@ def _expected_packaging(self, record, **kw): data.update(kw) return data + def _expected_delivery_packaging(self, record, **kw): + data = { + "id": record.id, + "name": record.name, + "packaging_type": record.packaging_type_id.display_name, + "barcode": record.barcode, + } + data.update(kw) + return data + def _expected_storage_type(self, record, **kw): data = { "id": record.id, diff --git a/shopfloor/tests/test_checkout_list_delivery_packaging.py b/shopfloor/tests/test_checkout_list_delivery_packaging.py new file mode 100644 index 00000000000..48fc87c4463 --- /dev/null +++ b/shopfloor/tests/test_checkout_list_delivery_packaging.py @@ -0,0 +1,120 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo_test_helper import FakeModelLoader + +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +class CheckoutListDeliveryPackagingCase(CheckoutCommonCase, CheckoutSelectPackageMixin): + @classmethod + def _load_test_models(cls): + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .models import DeliveryCarrierTest, ProductPackagingTest + + cls.loader.update_registry((DeliveryCarrierTest, ProductPackagingTest)) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super(CheckoutListDeliveryPackagingCase, cls).tearDownClass() + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls._load_test_models() + cls.carrier = cls.env["delivery.carrier"].search([], limit=1) + cls.carrier.sudo().delivery_type = "test" + cls.picking = cls._create_picking( + lines=[ + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), + ] + ) + cls.picking.carrier_id = cls.carrier + cls.packaging_type = ( + cls.env["product.packaging.type"] + .sudo() + .create({"name": "Transport Box", "code": "TB", "sequence": 0}) + ) + cls.delivery_packaging1 = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box 1", + "package_carrier_type": "test", + "packaging_type_id": cls.packaging_type.id, + "barcode": "BOX1", + } + ) + ) + cls.delivery_packaging2 = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box 2", + "package_carrier_type": "test", + "packaging_type_id": cls.packaging_type.id, + "barcode": "BOX2", + } + ) + ) + cls.delivery_packaging = ( + cls.delivery_packaging1 | cls.delivery_packaging2 + ).sorted("name") + + def test_list_delivery_packaging_available(self): + self._fill_stock_for_moves(self.picking.move_lines, in_package=True) + self.picking.action_assign() + selected_lines = self.picking.move_line_ids + response = self.service.dispatch( + "list_delivery_packaging", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + }, + ) + self.assert_response( + response, + next_state="select_delivery_packaging", + data={ + "packaging": self.service.data.delivery_packaging_list( + self.delivery_packaging + ), + }, + ) + + def test_list_delivery_packaging_not_available(self): + self.delivery_packaging.package_carrier_type = False + self._fill_stock_for_moves(self.picking.move_lines, in_package=True) + self.picking.action_assign() + selected_lines = self.picking.move_line_ids + # for line in selected_lines: + # line.qty_done = line.product_uom_qty + response = self.service.dispatch( + "list_delivery_packaging", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + }, + ) + self.assert_response( + response, + next_state="select_package", + data={ + "picking": self._picking_summary_data(self.picking), + "selected_move_lines": [ + self._move_line_data(ml) for ml in selected_lines.sorted() + ], + "packing_info": self.service._data_for_packing_info(self.picking), + "no_package_enabled": not self.service.options.get( + "checkout__disable_no_package" + ), + }, + message=self.service.msg_store.no_delivery_packaging_available(), + ) From 5ebb201f4715a5d09772fb597e683a101f599af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 12 Mar 2021 15:46:49 +0100 Subject: [PATCH 568/710] shopfloor: checkout, select line after canceling Once a line or package has been canceled, return on the `select_line` state instead of `summary`. When we cancel a line or a package, there is a high chance that the user wants to restart again the operation. --- shopfloor/services/checkout.py | 7 ++++--- shopfloor/tests/test_checkout_cancel_line.py | 14 ++++---------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 49df73cf58a..23972e20453 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -1030,7 +1030,8 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): so they have to be processed again. Transitions: - * summary + * summary: if package or line are not found + * select_line: when package or line has been canceled """ picking = self.env["stock.picking"].browse(picking_id) message = self._check_picking_status(picking) @@ -1061,7 +1062,7 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): if line: line.write({"qty_done": 0, "shopfloor_checkout_done": False}) msg = _("Line cancelled") - return self._response_for_summary( + return self._response_for_select_line( picking, message={"message_type": "success", "body": msg} ) @@ -1475,7 +1476,7 @@ def set_packaging(self): return self._response_schema(next_states={"change_packaging", "summary"}) def cancel_line(self): - return self._response_schema(next_states={"summary"}) + return self._response_schema(next_states={"summary", "select_line"}) def done(self): return self._response_schema(next_states={"summary", "confirm_done"}) diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py index 6a9d4e74a7e..87156ea5c6f 100644 --- a/shopfloor/tests/test_checkout_cancel_line.py +++ b/shopfloor/tests/test_checkout_cancel_line.py @@ -86,11 +86,8 @@ def test_cancel_package_ok(self): self.assert_response( response, - next_state="summary", - data={ - "picking": self._stock_picking_data(picking, done=True), - "all_processed": False, - }, + next_state="select_line", + data={"picking": self._stock_picking_data(picking)}, message={"body": "Package cancelled", "message_type": "success"}, ) @@ -114,11 +111,8 @@ def test_cancel_line_ok(self): self.assert_response( response, - next_state="summary", - data={ - "picking": self._stock_picking_data(picking, done=True), - "all_processed": False, - }, + next_state="select_line", + data={"picking": self._stock_picking_data(picking)}, message={"body": "Line cancelled", "message_type": "success"}, ) From 20f4b44d5f0bf53f882ede6f3a17e722120374d1 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 15 Mar 2021 07:46:26 +0000 Subject: [PATCH 569/710] shopfloor 13.0.4.4.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 56d845ead9c..258a398f944 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.4.3.2", + "version": "13.0.4.4.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 77ae17378e4bd1e2d9750fe02b15ed978d785e2a Mon Sep 17 00:00:00 2001 From: oca-travis Date: Mon, 15 Mar 2021 08:53:43 +0000 Subject: [PATCH 570/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 0c6471b5d99..a2f8c736928 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -377,6 +377,12 @@ msgstr "" msgid "New move lines cannot be assigned: canceled." msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No delivery package type available." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -450,18 +456,11 @@ msgid "No quantity has been processed, unable to complete the transfer." msgstr "" #. module: shopfloor -#: code:addons/shopfloor/services/checkout.py:0 +#: code:addons/shopfloor/actions/message.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 @@ -600,7 +599,7 @@ msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format -msgid "Packaging {} does not match carrier {}." +msgid "Packaging {} is not allowed for carrier {}." msgstr "" #. module: shopfloor @@ -1173,6 +1172,12 @@ msgid "" "{product_desc}" msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} is not a valid destination package." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format From 7f32a62c23b2b417ce6a074a86c4cea10b475cd0 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 15 Mar 2021 11:06:49 +0000 Subject: [PATCH 571/710] shopfloor 13.0.4.4.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 258a398f944..fa07bee2bb2 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.4.4.0", + "version": "13.0.4.4.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 75b9debe0f62ee696f2083e59828679f4b9c888e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 16 Mar 2021 15:43:11 +0100 Subject: [PATCH 572/710] shopfloor: do not list delivery packaging w/ no carrier set --- shopfloor/services/checkout.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 23972e20453..f870ff3c435 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -789,7 +789,10 @@ def _packaging_good_for_carrier(self, packaging, carrier): return packaging.package_carrier_type in ("none", carrier.delivery_type) def _get_available_delivery_packaging(self, picking): - return self.env["product.packaging"].search( + model = self.env["product.packaging"] + if not picking.carrier_id: + return model.browse() + return model.search( [ ("product_id", "=", False), ( From b3d064a0873ded71b053ccb7a589cec0b427a839 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 23 Feb 2021 08:02:48 +0100 Subject: [PATCH 573/710] shopfloor: zone picking split before validating moves --- shopfloor/services/zone_picking.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 910ecc15b91..5522422f987 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1045,6 +1045,11 @@ def set_destination_all(self, barcode, confirmation=False): 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 + # TODO: update tests + for move in moves: + move.split_other_move_lines(buffer_lines & move.move_line_ids) stock = self._actions_for("stock") stock.validate_moves(moves) message = self.msg_store.buffer_complete() From 14c970f44a510f726f0bf2e8a311dca419e49880 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 16 Mar 2021 16:11:37 +0100 Subject: [PATCH 574/710] shopfloor: cluster picking fix check on package content If the package has no quants then it's empty. --- shopfloor/services/cluster_picking.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 64154c9d042..16b2795112b 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -596,9 +596,11 @@ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantit ) # the scanned package can contain only move lines of the same picking - if any( + if bin_package.quant_ids or any( ml.picking_id != move_line.picking_id - for ml in bin_package.planned_move_line_ids + for ml in bin_package.planned_move_line_ids.filtered( + lambda x: x.state not in ("done", "cancel") + ) ): return self._response_for_scan_destination( move_line, From be0ed23c6c1f3074ddbcde8c7ee3f3e4775545cb Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 23 Mar 2021 07:04:07 +0000 Subject: [PATCH 575/710] shopfloor 13.0.4.4.2 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index fa07bee2bb2..90a296bd5a9 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.4.4.1", + "version": "13.0.4.4.2", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 0c0bcbc4838d90bd307b76006d209259ec8996f0 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 23 Mar 2021 08:58:00 +0000 Subject: [PATCH 576/710] shopfloor 13.0.4.4.3 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 90a296bd5a9..6f0d9d7f1eb 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.4.4.2", + "version": "13.0.4.4.3", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 73b439825e7b4df5de98abe10841fac5979855a6 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 22 Mar 2021 14:10:32 +0100 Subject: [PATCH 577/710] shopfloor: unify msg for packed goods --- shopfloor/actions/message.py | 6 ++++++ shopfloor/i18n/es_AR.po | 2 +- shopfloor/i18n/shopfloor.pot | 2 +- shopfloor/services/checkout.py | 5 +---- shopfloor/tests/test_checkout_list_package.py | 5 +---- shopfloor/tests/test_checkout_new_package.py | 5 +---- .../tests/test_checkout_scan_package_action.py | 17 +++-------------- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index a1f523b534f..386e5b1200c 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -521,3 +521,9 @@ def no_delivery_packaging_available(self): "message_type": "warning", "body": _("No delivery package type available."), } + + def goods_packed_in(self, package): + return { + "message_type": "info", + "body": _("Goods packed into {0.name}").format(package), + } diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 27deb26fa11..338daa3b055 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -694,7 +694,7 @@ 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 {}" +msgid "Goods packed in {}" msgstr "Producto(s) empaquetado(s) en {}" #. module: shopfloor diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index a2f8c736928..d91ea7e0b5e 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -667,7 +667,7 @@ msgstr "" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format -msgid "Product(s) packed in {}" +msgid "Goods packed in {}" msgstr "" #. module: shopfloor diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index f870ff3c435..4eefa20f127 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -674,10 +674,7 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package): # go back to the screen to select the next lines to pack return self._response_for_select_line( picking, - message={ - "message_type": "success", - "body": _("Product(s) packed in {}").format(package.name), - }, + message=self.msg_store.goods_packed_in(package), ) def _prepare_vals_package_from_packaging(self, packaging): diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py index 81ea35ceac6..09a81b3421b 100644 --- a/shopfloor/tests/test_checkout_list_package.py +++ b/shopfloor/tests/test_checkout_list_package.py @@ -164,10 +164,7 @@ def _assert_package_set(self, 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": "success", - "body": "Product(s) packed in {}".format(self.delivery_package.name), - }, + message=self.msg_store.goods_packed_in(self.delivery_package), ) def test_scan_dest_package_ok(self): diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py index a6a038f72f0..79126597a04 100644 --- a/shopfloor/tests/test_checkout_new_package.py +++ b/shopfloor/tests/test_checkout_new_package.py @@ -57,8 +57,5 @@ def test_new_package_ok(self): # 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": "success", - "body": "Product(s) packed in {}".format(new_package.name), - }, + message=self.msg_store.goods_packed_in(new_package), ) diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index c8b5f014f24..39c87e92aef 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -275,10 +275,7 @@ def test_scan_package_action_scan_package_use_existing_package_ok(self): "picking": self._stock_picking_data(picking, done=True), "all_processed": True, }, - message={ - "message_type": "success", - "body": "Product(s) packed in {}".format(package.name), - }, + message=self.msg_store.goods_packed_in(package), ) def test_scan_package_action_scan_packaging_ok(self): @@ -363,10 +360,7 @@ def test_scan_package_action_scan_packaging_ok(self): response, next_state="select_line", data={"picking": self._stock_picking_data(picking)}, - message={ - "message_type": "success", - "body": "Product(s) packed in {}".format(new_package.name), - }, + message=self.msg_store.goods_packed_in(new_package), ) def test_scan_package_action_scan_packaging_bad_carrier(self): @@ -434,12 +428,7 @@ def test_scan_package_action_scan_packaging_bad_carrier(self): ) self.assertEqual( response["message"], - { - "message_type": "success", - "body": "Product(s) packed in {}".format( - selected_lines.result_package_id.name - ), - }, + self.msg_store.goods_packed_in(selected_lines.result_package_id), ) def test_scan_package_action_scan_not_found(self): From 94788a1bf1a1ef1fec8a89c87c6154a0cd171763 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 22 Mar 2021 14:12:43 +0100 Subject: [PATCH 578/710] shopfloor: message.packaging_invalid_for_carrier made defensive --- shopfloor/actions/message.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 386e5b1200c..99fd6c8e9f0 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -499,8 +499,8 @@ def package_open(self): def packaging_invalid_for_carrier(self, packaging, carrier): return { "message_type": "error", - "body": _("Packaging {} is not allowed for carrier {}.").format( - packaging.name, carrier.name + "body": _("Packaging '{}' is not allowed for carrier {}.").format( + packaging.name if packaging else _("No value"), carrier.name ), } From 8ed60190f70d2a8092edb969b1060167ba5b2e87 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 22 Mar 2021 14:14:43 +0100 Subject: [PATCH 579/710] shopfloor: unify actions for packaging --- shopfloor/actions/__init__.py | 1 + shopfloor/actions/packaging.py | 41 ++++++++++++++++++++++++++++++++++ shopfloor/services/checkout.py | 24 +++++--------------- 3 files changed, 47 insertions(+), 19 deletions(-) create mode 100644 shopfloor/actions/packaging.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index 0192f5cd2b1..32d2adc86ae 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -11,3 +11,4 @@ from . import savepoint from . import move_line_search from . import stock +from . import packaging diff --git a/shopfloor/actions/packaging.py b/shopfloor/actions/packaging.py new file mode 100644 index 00000000000..e8809f448a8 --- /dev/null +++ b/shopfloor/actions/packaging.py @@ -0,0 +1,41 @@ +# Copyright 2021 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 PackagingAction(Component): + """Provide methods to work with packaging operations.""" + + _name = "shopfloor.packaging.action" + _inherit = "shopfloor.process.action" + _usage = "packaging" + + def packaging_valid_for_carrier(self, packaging, carrier): + return packaging.package_carrier_type in ("none", carrier.delivery_type) + + def create_delivery_package(self, carrier): + delivery_type = carrier.delivery_type + # TODO: refactor `delivery_[carrier_name]` modules + # to have always the same field named `default_packaging_id` + # to unify lookup of this field. + # As alternative add a computed field. + default_packaging = carrier[delivery_type + "_default_packaging_id"] + return self.create_package_from_packaging(default_packaging) + + def create_package_from_packaging(self, packaging=None): + if packaging: + vals = self._package_vals_from_packaging(packaging) + else: + vals = self._package_vals_without_packaging() + return self.env["stock.quant.package"].create(vals) + + def _package_vals_from_packaging(self, packaging): + return { + "packaging_id": packaging.id, + "lngth": packaging.lngth, + "width": packaging.width, + "height": packaging.height, + } + + def _package_vals_without_packaging(self): + return {} diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 4eefa20f127..c72beb0f971 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -673,27 +673,12 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package): ) # go back to the screen to select the next lines to pack return self._response_for_select_line( - picking, - message=self.msg_store.goods_packed_in(package), + picking, message=self.msg_store.goods_packed_in(package), ) - def _prepare_vals_package_from_packaging(self, packaging): - return { - "packaging_id": packaging.id, - "lngth": packaging.lngth, - "width": packaging.width, - "height": packaging.height, - } - - 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) + actions = self._actions_for("packaging") + package = actions.create_package_from_packaging(packaging=packaging) return self._put_lines_in_allowed_package(picking, selected_lines, package) def scan_package_action(self, picking_id, selected_line_ids, barcode): @@ -783,7 +768,8 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): ) def _packaging_good_for_carrier(self, packaging, carrier): - return packaging.package_carrier_type in ("none", carrier.delivery_type) + actions = self._actions_for("packaging") + return actions.packaging_valid_for_carrier(packaging, carrier) def _get_available_delivery_packaging(self, picking): model = self.env["product.packaging"] From bd2a7cf5ed09c1526b0f4d9b97987758d13f7191 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 22 Mar 2021 14:18:13 +0100 Subject: [PATCH 580/710] shopfloor: zone_picking fix diagram ref --- 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 5522422f987..e6c07aa1e4f 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -20,7 +20,7 @@ class ZonePicking(Component): You will find a sequence diagram describing states and endpoints relationships [here](../docs/zone_picking_diag_seq.png). - Keep [the sequence diagram](../docs/delivery_diag_seq.plantuml) + Keep [the sequence diagram](../docs/zone_picking_diag_seq.plantuml) up-to-date if you change endpoints. Note: From 3ecbcbb564ea9ed045bbef85a1e7b3adfcabc5ce Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Mon, 15 Mar 2021 08:54:13 +0000 Subject: [PATCH 581/710] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/ --- shopfloor/i18n/es_AR.po | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 338daa3b055..da37cb3cacf 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -392,6 +392,12 @@ msgstr "Cantidad negativa no permitida." msgid "New move lines cannot be assigned: canceled." 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 delivery package type available." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -468,17 +474,11 @@ msgstr "" "transferencia." #. module: shopfloor -#: code:addons/shopfloor/services/checkout.py:0 +#: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No valid package to select." msgstr "No hay paquete válido para seleccionar." -#. module: shopfloor -#: code:addons/shopfloor/services/checkout.py:0 -#, python-format -msgid "Not a valid destination package" -msgstr "No es un paquete de destino válido" - #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -624,8 +624,8 @@ msgstr "El Empaquetado cambió en el paquete {}" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format -msgid "Packaging {} does not match carrier {}." -msgstr "El Empaquetado {} no coincide con el transportista {}." +msgid "Packaging {} is not allowed for carrier {}." +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 @@ -1222,12 +1222,24 @@ 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 "{} is not a valid destination package." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "{} {} put in {}" msgstr "{} {} poner en {}" +#~ msgid "Not a valid destination package" +#~ msgstr "No es un paquete de destino válido" + +#~ msgid "Packaging {} does not match carrier {}." +#~ msgstr "El Empaquetado {} no coincide con el transportista {}." + #~ msgid "%s updated." #~ msgstr "%s actualizado." From 53668adfc13533d2d3debf4839641962207fa2b0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 6 Apr 2021 09:28:48 +0200 Subject: [PATCH 582/710] shopfloor: improve tests for cluster_picking.scan_line --- .../tests/test_cluster_picking_scan_line.py | 68 ++++++++++++++++--- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/shopfloor/tests/test_cluster_picking_scan_line.py b/shopfloor/tests/test_cluster_picking_scan_line.py index 626779a7f9e..9cd89033e8f 100644 --- a/shopfloor/tests/test_cluster_picking_scan_line.py +++ b/shopfloor/tests/test_cluster_picking_scan_line.py @@ -222,12 +222,22 @@ def test_scan_line_location_error_several_package(self): 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({}), + new_move = line.move_id.copy() + self._fill_stock_for_moves(new_move, in_package=True, same_package=False) + new_move._action_confirm(merge=False) + new_move._action_assign() + loc_lines = self.env["stock.move.line"].search( + [ + ("picking_id.picking_type_id", "=", self.picking_type.id), + ("location_id", "=", location.id), + ] ) + # Ensure lines have different packages and no qty done + self.assertEqual(len(loc_lines), 2) + self.assertEqual(len(loc_lines.mapped("package_id")), 2) + self.assertEqual(loc_lines.mapped("qty_done"), [0.0, 0.0]) + # All lines have to be processed, + # we cannot go further without scanning a specific package self._scan_line_error( line, location.barcode, @@ -236,6 +246,10 @@ def test_scan_line_location_error_several_package(self): "body": "Several packages found in Stock, please scan a package.", }, ) + # Although, if one line is already processed, + # we can work automatically on the next one + line.qty_done = line.product_uom_qty + self._scan_line_ok(new_move.move_line_ids[0], line.location_id.barcode) def test_scan_line_location_error_several_products(self): """Scan to check if user scans a correct location for current line @@ -247,6 +261,22 @@ def test_scan_line_location_error_several_products(self): location = line.location_id # add a second product in the location self._update_qty_in_location(location, self.product_b, 10) + # add a second product in the location + new_move = line.move_id.copy({"product_id": self.product_c.id}) + self._fill_stock_for_moves(new_move) + new_move._action_confirm(merge=False) + new_move._action_assign() + loc_lines = self.env["stock.move.line"].search( + [ + ("picking_id.picking_type_id", "=", self.picking_type.id), + ("location_id", "=", location.id), + ] + ) + # Ensure lines have no package, 2 products and no qty done + self.assertEqual(len(loc_lines), 2) + self.assertEqual(len(loc_lines.mapped("package_id")), 0) + self.assertEqual(len(loc_lines.mapped("product_id")), 2) + self.assertEqual(loc_lines.mapped("qty_done"), [0.0, 0.0]) self._scan_line_error( line, location.barcode, @@ -255,6 +285,10 @@ def test_scan_line_location_error_several_products(self): "body": "Several products found in Stock, please scan a product.", }, ) + # Although, if one line is already processed, + # we can work automatically on the next one + line.qty_done = line.product_uom_qty + self._scan_line_ok(new_move.move_line_ids[0], line.location_id.barcode) def test_scan_line_location_error_several_lots(self): """Scan to check if user scans a correct location for current line @@ -264,11 +298,23 @@ def test_scan_line_location_error_several_lots(self): 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) + new_move = line.move_id.copy() + self._fill_stock_for_moves(new_move, in_lot=True) + new_move._action_confirm(merge=False) + new_move._action_assign() + loc_lines = self.env["stock.move.line"].search( + [ + ("picking_id.picking_type_id", "=", self.picking_type.id), + ("location_id", "=", location.id), + ] + ) + # Ensure lines have no package, 1 product, 2 lots and no qty done + self.assertEqual(len(loc_lines), 2) + self.assertEqual(len(loc_lines.mapped("package_id")), 0) + self.assertEqual(len(loc_lines.mapped("product_id")), 1) + self.assertEqual(len(loc_lines.mapped("lot_id")), 2) + self.assertEqual(loc_lines.mapped("qty_done"), [0.0, 0.0]) self._scan_line_error( line, location.barcode, @@ -277,6 +323,10 @@ def test_scan_line_location_error_several_lots(self): "body": "Several lots found in Stock, please scan a lot.", }, ) + # Although, if one line is already processed, + # we can work automatically on the next one + line.qty_done = line.product_uom_qty + self._scan_line_ok(new_move.move_line_ids[0], line.location_id.barcode) def test_scan_line_error_not_found(self): """Nothing found for the barcode""" From f6c885f4dad9d6da76de683612cdf6c4a90f832d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 6 Apr 2021 09:34:08 +0200 Subject: [PATCH 583/710] shopfloor: imp cluster_picking._scan_line_by_location When processing a cluster of pickings we are working on existing move lines. When we scan a location and there are some lines already processed we can work on the next one. There's no need to block users to ask to scan specific package/lot/product if the system can determine that there's a line that can be safely processed. Hence, _scan_line_by_location now apply the check only on pending move lines matching the type of operation we are working on. --- shopfloor/services/cluster_picking.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 16b2795112b..53b980e6ce6 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -515,18 +515,10 @@ def _scan_line_by_location(self, picking, move_line, location): # 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) + ml_search = self.search_move_line + pending_lines = ml_search.search_move_lines_by_location(location) + + lots = pending_lines.mapped("lot_id") if len(lots) > 1: return self._response_for_start_line( @@ -534,7 +526,10 @@ def _scan_line_by_location(self, picking, move_line, location): message=self.msg_store.several_lots_in_location(move_line.location_id), ) - if len(packages | products) > 1: + packages = pending_lines.mapped("package_id") + products = pending_lines.mapped("product_id") + + if len(packages) > 1 or len(products) > 1: if move_line.package_id: return self._response_for_start_line( move_line, From 3be63562e18f00d6067069980ef9a8327874e233 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 6 Apr 2021 09:42:24 +0200 Subject: [PATCH 584/710] shopfloor: cluster_picking._scan_line* fix docstrings --- shopfloor/services/cluster_picking.py | 29 ++++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 53b980e6ce6..ad27d20e97d 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -468,16 +468,19 @@ def scan_line(self, picking_batch_id, move_line_id, barcode): ) def _scan_line_by_package(self, picking, move_line, package): + """Package scanned, just work with it.""" return self._response_for_scan_destination(move_line) def _scan_line_by_product(self, picking, move_line, product): + """Product scanned, check if we can work with it. + + 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 + """ if move_line.product_id.tracking in ("lot", "serial"): return self._response_for_start_line( move_line, message=self.msg_store.scan_lot_on_product_tracked_by_lot() ) - - # 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 and l.location_id == move_line.location_id ) @@ -494,8 +497,11 @@ def _scan_line_by_product(self, picking, move_line, product): return self._response_for_scan_destination(move_line) def _scan_line_by_lot(self, picking, move_line, lot): - # 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 + """Lot scanned, check if we can work with it. + + 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 @@ -509,11 +515,14 @@ 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): - # 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 scanned, check if we can work on goods contained into it. + + 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 ml_search = self.search_move_line pending_lines = ml_search.search_move_lines_by_location(location) From 7e1d5d19fac335730bcd1c7b7747d179c28b91f3 Mon Sep 17 00:00:00 2001 From: jabelchi Date: Tue, 13 Apr 2021 09:09:13 +0000 Subject: [PATCH 585/710] Added translation using Weblate (Catalan) --- shopfloor/i18n/ca.po | 1186 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1186 insertions(+) create mode 100644 shopfloor/i18n/ca.po diff --git a/shopfloor/i18n/ca.po b/shopfloor/i18n/ca.po new file mode 100644 index 00000000000..1c99a26e59a --- /dev/null +++ b/shopfloor/i18n/ca.po @@ -0,0 +1,1186 @@ +# 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: ca\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/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,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 +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Are you sure?" +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:shopfloor.scenario,name:shopfloor.scenario_checkout +#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo +msgid "Checkout" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_cluster_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_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 +#: 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 +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 +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:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery +#: model:shopfloor.scenario,name:shopfloor.scenario_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_priority_postpone_mixin__display_name +msgid "Display Name" +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,field_description:shopfloor.field_stock_move_line__location_id +msgid "From" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__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_priority_postpone_mixin____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__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 +#: model:ir.model,name:shopfloor.model_shopfloor_log +msgid "Legacy model for tracking REST calls: replacedy by rest.log" +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 +#: model:shopfloor.scenario,name:shopfloor.scenario_location_content_transfer +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 +#: 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.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 +#: 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 delivery package type available." +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 {}." +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/actions/message.py:0 +#, python-format +msgid "No valid package to select." +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 +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging {} is not allowed for carrier {}." +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.model,name:shopfloor.model_stock_quant +msgid "Quants" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Real pack weight or the estimated one." +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_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_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 "Same package {} is already assigned." +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 +#: 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 +#: 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_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.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.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Shopfloor weight (kg)" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_single_pack_transfer +msgid "Single Pack Transfer" +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,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,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/actions/message.py:0 +#, python-format +msgid "The content of {} cannot be transferred with this scenario." +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 cannot be transferred with this scenario." +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/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: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 +#: 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:shopfloor.scenario,name:shopfloor.scenario_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 "{} is not a valid destination package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} {} put in {}" +msgstr "" From 9c8e174e814a671f1d7aa709ac43992cf45ba14c Mon Sep 17 00:00:00 2001 From: jabelchi Date: Tue, 13 Apr 2021 09:10:02 +0000 Subject: [PATCH 586/710] Translated using Weblate (Catalan) Currently translated at 1.0% (2 of 196 strings) Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/ca/ --- shopfloor/i18n/ca.po | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/shopfloor/i18n/ca.po b/shopfloor/i18n/ca.po index 1c99a26e59a..d588033c991 100644 --- a/shopfloor/i18n/ca.po +++ b/shopfloor/i18n/ca.po @@ -6,25 +6,27 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 13.0\n" "Report-Msgid-Bugs-To: \n" -"Last-Translator: Automatically generated\n" +"PO-Revision-Date: 2021-04-13 11:46+0000\n" +"Last-Translator: jabelchi \n" "Language-Team: none\n" "Language: ca\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/actions/message.py:0 #, python-format msgid "A destination package is required." -msgstr "" +msgstr "Es requereix un paquet destí." #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 #, python-format msgid "A draft inventory has been created for control." -msgstr "" +msgstr "S'ha creat un inventari esborrany per a control." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check From 4cdb55e3e12927dc88f6654e4acf796abf933b0b Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Wed, 14 Apr 2021 18:17:53 +0000 Subject: [PATCH 587/710] Translated using Weblate (Spanish (Argentina)) Currently translated at 100.0% (196 of 196 strings) Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/es_AR/ --- shopfloor/i18n/es_AR.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index da37cb3cacf..9f39bd0302b 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 13.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2021-02-25 06:45+0000\n" +"PO-Revision-Date: 2021-04-14 20:46+0000\n" "Last-Translator: Ignacio Buioli \n" "Language-Team: none\n" "Language: es_AR\n" @@ -396,7 +396,7 @@ msgstr "Los nuevos movimientos de líneas no puede ser asignados: cancelados." #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No delivery package type available." -msgstr "" +msgstr "No hay un tipo de paquete de entrega disponible." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -625,7 +625,7 @@ msgstr "El Empaquetado cambió en el paquete {}" #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Packaging {} is not allowed for carrier {}." -msgstr "" +msgstr "Empaquetado {} no está permitido para en transportista {}." #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 @@ -1226,7 +1226,7 @@ msgstr "" #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "{} is not a valid destination package." -msgstr "" +msgstr "{} no es un paquete de destino válido." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 From 312de69785852dfc56f18884f13400c8b90bb1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 29 Mar 2021 15:23:08 +0200 Subject: [PATCH 588/710] shopfloor, checkout: get right carrier When processing pick/pack operation we have no carrier set, we have to use the carrier of the related delivery operation. --- shopfloor/__manifest__.py | 2 ++ shopfloor/services/checkout.py | 11 ++++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 6f0d9d7f1eb..a620a224b9c 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -43,6 +43,8 @@ "delivery", # OCA / product-attribute "product_packaging_type", + # OCA / delivery + "stock_picking_delivery_link", ], "data": [ "data/shopfloor_scenario_data.xml", diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index c72beb0f971..9084a7b0800 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -749,7 +749,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): # Scan delivery packaging packaging = search.generic_packaging_from_scan(barcode) if packaging: - carrier = picking.carrier_id + carrier = picking.ship_carrier_id or picking.carrier_id # Validate against carrier if carrier and not self._packaging_good_for_carrier(packaging, carrier): return self._response_for_select_package( @@ -773,16 +773,13 @@ def _packaging_good_for_carrier(self, packaging, carrier): def _get_available_delivery_packaging(self, picking): model = self.env["product.packaging"] - if not picking.carrier_id: + carrier = picking.ship_carrier_id or picking.carrier_id + if not carrier: return model.browse() return model.search( [ ("product_id", "=", False), - ( - "package_carrier_type", - "=", - picking.carrier_id.delivery_type or "none", - ), + ("package_carrier_type", "=", carrier.delivery_type or "none"), ], order="name", ) From 75e7f790329d7e6aa2adddb7f0de89932cde53d3 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 22 Mar 2021 14:17:45 +0100 Subject: [PATCH 589/710] shopfloor: zone_picking add pick+pack option Zone picking can now be used for pick + pack operation at the same time. When the flag `pick_pack_same_time` is enabled (via menu conf) set destination will work as follow: * if a location is scanned, a new delivery package compliant w/ selected carrier is created * if a package is scanned, the package is validated against the carrier * in both cases, if the picking has no carrier the operation fails --- shopfloor/__manifest__.py | 2 +- shopfloor/actions/message.py | 9 + shopfloor/actions/packaging.py | 12 +- shopfloor/data/shopfloor_scenario_data.xml | 5 + .../migrations/13.0.4.5.0/post-migration.py | 21 ++ shopfloor/models/shopfloor_menu.py | 31 +++ shopfloor/services/zone_picking.py | 74 +++++++ shopfloor/tests/__init__.py | 1 + shopfloor/tests/models.py | 3 + ..._picking_set_line_destination_pick_pack.py | 207 ++++++++++++++++++ shopfloor/views/shopfloor_menu.xml | 7 + 11 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 shopfloor/migrations/13.0.4.5.0/post-migration.py create mode 100644 shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index a620a224b9c..7113f8e9324 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.4.4.3", + "version": "13.0.4.5.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 99fd6c8e9f0..bdf8c6978cd 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -527,3 +527,12 @@ def goods_packed_in(self, package): "message_type": "info", "body": _("Goods packed into {0.name}").format(package), } + + def picking_without_carrier_cannot_pack(self, picking): + return { + "message_type": "error", + "body": _( + "Pick + Pack mode ON: the picking {0.name} has no carrier set. " + "The system couldn't pack goods automatically." + ).format(picking), + } diff --git a/shopfloor/actions/packaging.py b/shopfloor/actions/packaging.py index e8809f448a8..e2d4f139355 100644 --- a/shopfloor/actions/packaging.py +++ b/shopfloor/actions/packaging.py @@ -14,13 +14,19 @@ def packaging_valid_for_carrier(self, packaging, carrier): return packaging.package_carrier_type in ("none", carrier.delivery_type) def create_delivery_package(self, carrier): - delivery_type = carrier.delivery_type + default_packaging = self._get_default_packaging(carrier) + return self.create_package_from_packaging(default_packaging) + + def _get_default_packaging(self, carrier): # TODO: refactor `delivery_[carrier_name]` modules # to have always the same field named `default_packaging_id` # to unify lookup of this field. # As alternative add a computed field. - default_packaging = carrier[delivery_type + "_default_packaging_id"] - return self.create_package_from_packaging(default_packaging) + # AFAIS there's no reason to have 1 field per carrier type. + fname = carrier.delivery_type + "_default_packaging_id" + if fname not in carrier._fields: + return self.env["product.packaging"].browse() + return carrier[fname] def create_package_from_packaging(self, packaging=None): if packaging: diff --git a/shopfloor/data/shopfloor_scenario_data.xml b/shopfloor/data/shopfloor_scenario_data.xml index 0a2370d160b..a4128371a07 100644 --- a/shopfloor/data/shopfloor_scenario_data.xml +++ b/shopfloor/data/shopfloor_scenario_data.xml @@ -14,6 +14,11 @@ Zone Picking zone_picking + +{ + "pick_pack_same_time": true +} + Cluster Picking diff --git a/shopfloor/migrations/13.0.4.5.0/post-migration.py b/shopfloor/migrations/13.0.4.5.0/post-migration.py new file mode 100644 index 00000000000..1a3956b7db7 --- /dev/null +++ b/shopfloor/migrations/13.0.4.5.0/post-migration.py @@ -0,0 +1,21 @@ +# Copyright 2021 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(__name__) + + +def migrate(cr, version): + env = api.Environment(cr, SUPERUSER_ID, {}) + + _logger.info("Update zone picking settings") + zp = env.ref("shopfloor.scenario_zone_picking", raise_if_not_found=False) + if zp: + options = zp.options + if "pick_pack_same_time" not in options: + options.update({"pick_pack_same_time": True}) + zp.options_edit = json.dumps(options) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 90673dc4b27..fbe821cae02 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -2,6 +2,15 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, api, exceptions, fields, models +PICK_PACK_SAME_TIME_HELP = """ +If you tick this box, while picking goods from a location +(eg: zone picking) set destination will work as follow: + +* if a location is scanned, a new delivery package is created; +* if a package is scanned, the package is validated against the carrier +* in both cases, if the picking has no carrier the operation fails.", +""" + class ShopfloorMenu(models.Model): _inherit = "shopfloor.menu" @@ -38,6 +47,21 @@ class ShopfloorMenu(models.Model): "if the put-away can find a sublocation (when putaway destination " "is different from the operation type's destination).", ) + # TODO: refactor handling of these options. + # Possible solution: + # * field should stay on the scenario and get stored in options + # * field should use `sf_scenario` (eg: sf_scenario=("zone_picking", )) + # to control for which scenario it will be available + # * on the menu form, display a button to edit configurations + # and display a summary + pick_pack_same_time = fields.Boolean( + string="Pick and pack at the same time", + default=False, + help=PICK_PACK_SAME_TIME_HELP, + ) + pick_pack_same_time_is_possible = fields.Boolean( + compute="_compute_pick_pack_same_time_is_possible" + ) @api.depends("scenario_id", "picking_type_ids") def _compute_move_create_is_possible(self): @@ -66,6 +90,13 @@ def _compute_unreserve_other_moves_is_possible(self): "allow_unreserve_other_moves" ) + @api.depends("scenario_id") + def _compute_pick_pack_same_time_is_possible(self): + for menu in self: + menu.pick_pack_same_time_is_possible = menu.scenario_id.has_option( + "pick_pack_same_time" + ) + @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 diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index e6c07aa1e4f..876cb3d00c0 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -149,6 +149,9 @@ def picking_type(self): def lines_order(self): return getattr(self.work, "current_lines_order", "priority") + def _pick_pack_same_time(self): + return self.work.menu.pick_pack_same_time + def _response_for_start(self, message=None): zones = self.work.menu.picking_type_ids.mapped( "default_location_src_id.child_ids" @@ -744,19 +747,45 @@ def set_destination( search = self._actions_for("search") accept_only_package = not self._move_line_full_qty(move_line, quantity) + extra_message = "" if not accept_only_package: # When the barcode is a location location = search.location_from_scan(barcode) if location: + if self._pick_pack_same_time(): + ( + good_for_packing, + message, + ) = self._handle_pick_pack_same_time_for_location(move_line) + # TODO: we should append the msg instead. + # To achieve this, we should refactor `response.message` to a list + # or, to no break backward compat, we could add `extra_messages` + # to allow backend to send a main message and N additional messages. + extra_message = message + if not good_for_packing: + return self._response_for_set_line_destination( + move_line, message=message + ) pkg_moved, response = self._set_destination_location( move_line, quantity, confirmation, location, ) if response: + if extra_message: + response["message"]["body"] += "\n" + extra_message["body"] return response # When the barcode is a package package = search.package_from_scan(barcode) if package: + if self._pick_pack_same_time(): + ( + good_for_packing, + message, + ) = self._handle_pick_pack_same_time_for_package(move_line, package) + if not good_for_packing: + return self._response_for_set_line_destination( + move_line, message=message + ) location = move_line.location_dest_id pkg_moved, response = self._set_destination_package( move_line, quantity, package @@ -776,11 +805,56 @@ def set_destination( if pkg_moved: message = self.msg_store.confirm_pack_moved() + if extra_message: + message["body"] += "\n" + extra_message["body"] # Process the next line response = self.list_move_lines() return self._response(base_response=response, message=message) + def _handle_pick_pack_same_time_for_location(self, move_line): + """Automatically put product in carrier-specific package. + + :param move_line: current move line to process + :return: tuple like ($succes_flag, $success_or_failure_message) + """ + good_for_packing = False + message = "" + picking = move_line.picking_id + carrier = picking.ship_carrier_id or picking.carrier_id + if carrier: + actions = self._actions_for("packaging") + pkg = actions.create_delivery_package(carrier) + move_line.write({"result_package_id": pkg.id}) + message = self.msg_store.goods_packed_in(pkg) + good_for_packing = True + else: + message = self.msg_store.picking_without_carrier_cannot_pack(picking) + return good_for_packing, message + + def _handle_pick_pack_same_time_for_package(self, move_line, package): + """Validate package for packing at the same time. + + :param move_line: current move line to process + :param package: package to validate + :return: tuple like ($succes_flag, $success_or_failure_message) + """ + good_for_packing = False + message = None + picking = move_line.picking_id + carrier = picking.ship_carrier_id or picking.carrier_id + if carrier: + actions = self._actions_for("packaging") + if actions.packaging_valid_for_carrier(package.packaging_id, carrier): + good_for_packing = True + else: + message = self.msg_store.packaging_invalid_for_carrier( + package.packaging_id, carrier + ) + else: + message = self.msg_store.picking_without_carrier_cannot_pack(picking) + return good_for_packing, message + 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/__init__.py b/shopfloor/tests/__init__.py index ffe883632e5..77f29f70803 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -54,6 +54,7 @@ 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_set_line_destination_pick_pack 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/models.py b/shopfloor/tests/models.py index 7fef2079312..ca9aa69c3c2 100644 --- a/shopfloor/tests/models.py +++ b/shopfloor/tests/models.py @@ -14,6 +14,9 @@ class DeliveryCarrierTest(models.Model): _inherit = "delivery.carrier" delivery_type = fields.Selection(selection_add=[("test", "TEST")]) + test_default_packaging_id = fields.Many2one( + "product.packaging", string="Default Package Type" + ) class ProductPackagingTest(models.Model): diff --git a/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py b/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py new file mode 100644 index 00000000000..377f4ad4552 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py @@ -0,0 +1,207 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo_test_helper import FakeModelLoader + +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingSetLineDestinationPickPackCase(ZonePickingCommonCase): + """Tests set_line_destination when `pick_pack_same_time` is one + + * /set_destination + + """ + + @classmethod + def _load_test_models(cls): + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .models import DeliveryCarrierTest, ProductPackagingTest + + cls.loader.update_registry((DeliveryCarrierTest, ProductPackagingTest)) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls._load_test_models() + cls.carrier = cls.env["delivery.carrier"].search([], limit=1) + default_pkging = ( + cls.env["product.packaging"].sudo().create({"name": "TEST DEFAULT"}) + ) + cls.carrier.sudo().write( + { + "delivery_type": "test", + "integration_level": "rate", # avoid sending emails + "test_default_packaging_id": default_pkging.id, + } + ) + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + self.menu.sudo().pick_pack_same_time = True + + def test_set_destination_location_no_carrier(self): + """Scan location but carrier not set on picking + """ + 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 + # Confirm the destination with the right destination + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.product_uom_qty, + "confirmation": True, + }, + ) + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.picking_without_carrier_cannot_pack( + move_line.picking_id + ), + ) + + def test_set_destination_location_ok_carrier(self): + """When carried is set goods are packed into new delivery package.""" + existing_packages = self.env["stock.quant.package"].search([]) + 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 + move_line.picking_id.carrier_id = self.carrier + response = self.service.dispatch( + "set_destination", + params={ + "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() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + delivery_pkg = move_line.result_package_id + self.assertNotIn(delivery_pkg, existing_packages) + self.assertEqual( + delivery_pkg.packaging_id, self.carrier.test_default_packaging_id + ) + message = self.msg_store.confirm_pack_moved() + message["body"] += "\n" + self.msg_store.goods_packed_in(delivery_pkg)["body"] + self.assert_response_select_line( + response, zone_location, picking_type, move_lines, message=message, + ) + + def test_set_destination_package_full_qty_no_carrier(self): + """Scan destination package, no carrier on picking. + """ + 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={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.product_uom_qty, + "confirmation": True, + }, + ) + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.picking_without_carrier_cannot_pack( + move_line.picking_id + ), + ) + + def test_set_destination_package_full_qty_ok_carrier_bad_package(self): + """Scan destination package, carrier on picking, package invalid. + """ + 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 + move_line.picking_id.carrier_id = self.carrier + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.product_uom_qty, + "confirmation": False, + }, + ) + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.packaging_invalid_for_carrier( + self.free_package.packaging_id, self.carrier + ), + ) + + def test_set_destination_package_full_qty_ok_carrier_ok_package(self): + """Scan destination package, carrier on picking, package valid. + """ + 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 + move_line.picking_id.carrier_id = self.carrier + self.free_package.packaging_id = self.carrier.test_default_packaging_id + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.product_uom_qty, + "confirmation": True, + }, + ) + # 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() + 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, + message=self.service.msg_store.confirm_pack_moved(), + ) diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 6167d6a6447..e6d1c8c202f 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -33,6 +33,13 @@ + + + + From e640b06ad111cbe2f266d6c73ae684696527d86f Mon Sep 17 00:00:00 2001 From: oca-travis Date: Tue, 20 Apr 2021 10:52:54 +0000 Subject: [PATCH 590/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 50 +++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index d91ea7e0b5e..2a75749cce9 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -13,6 +13,18 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "" +"\n" +"If you tick this box, while picking goods from a location\n" +"(eg: zone picking) set destination will work as follow:\n" +"\n" +"* if a location is scanned, a new delivery package is created;\n" +"* if a package is scanned, the package is validated against the carrier\n" +"* in both cases, if the picking has no carrier the operation fails.\",\n" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -192,6 +204,12 @@ msgstr "" msgid "From" msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Goods packed into {0.name}" +msgstr "" + #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id @@ -461,6 +479,12 @@ msgstr "" msgid "No valid package to select." msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No value" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -590,6 +614,12 @@ msgstr "" msgid "Packages" msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging '{}' is not allowed for carrier {}." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -599,7 +629,19 @@ msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format -msgid "Packaging {} is not allowed for carrier {}." +msgid "" +"Pick + Pack mode ON: the picking {0.name} has no carrier set. The system " +"couldn't pack goods automatically." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time_is_possible +msgid "Pick Pack Same Time Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "Pick and pack at the same time" msgstr "" #. module: shopfloor @@ -664,12 +706,6 @@ msgstr "" msgid "Product {} belongs to a picking without a valid state." msgstr "" -#. module: shopfloor -#: code:addons/shopfloor/services/checkout.py:0 -#, python-format -msgid "Goods packed in {}" -msgstr "" - #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format From 981d8f27183fbc185d3588026230db19719e63bd Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 20 Apr 2021 11:10:47 +0000 Subject: [PATCH 591/710] shopfloor 13.0.4.6.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 7113f8e9324..2d28f9726ff 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.4.5.0", + "version": "13.0.4.6.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From de343e48783ca3f9ebfeb15ca1b501ed032d770f Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 20 Apr 2021 12:00:48 +0000 Subject: [PATCH 592/710] shopfloor 13.0.4.6.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 2d28f9726ff..cb5a517614e 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.4.6.0", + "version": "13.0.4.6.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 45d6587405a9cbfb4b86d8e8cae75631e8b10681 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 20 Apr 2021 13:11:12 +0000 Subject: [PATCH 593/710] shopfloor 13.0.4.7.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index cb5a517614e..bd75d9e74ad 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.4.6.1", + "version": "13.0.4.7.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 8c4e243fa4e03e50b948ca17b3415e6a799c5ed8 Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Tue, 20 Apr 2021 11:11:03 +0000 Subject: [PATCH 594/710] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/ --- shopfloor/i18n/ca.po | 86 ++++++++++++++++++++++++++++------------- shopfloor/i18n/es_AR.po | 58 +++++++++++++++++++++++---- 2 files changed, 109 insertions(+), 35 deletions(-) diff --git a/shopfloor/i18n/ca.po b/shopfloor/i18n/ca.po index d588033c991..a47058a5e6a 100644 --- a/shopfloor/i18n/ca.po +++ b/shopfloor/i18n/ca.po @@ -16,6 +16,18 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "X-Generator: Weblate 4.3.2\n" +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "" +"\n" +"If you tick this box, while picking goods from a location\n" +"(eg: zone picking) set destination will work as follow:\n" +"\n" +"* if a location is scanned, a new delivery package is created;\n" +"* if a package is scanned, the package is validated against the carrier\n" +"* in both cases, if the picking has no carrier the operation fails.\",\n" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -154,8 +166,8 @@ msgstr "" #: code:addons/shopfloor/models/stock_move.py:0 #, python-format msgid "" -"Created from backorder %s." +"Created from backorder " +"%s." msgstr "" #. module: shopfloor @@ -195,6 +207,12 @@ msgstr "" msgid "From" msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Goods packed into {0.name}" +msgstr "" + #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id @@ -204,8 +222,8 @@ 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 " +"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 "" @@ -261,8 +279,7 @@ msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/completion_info.py:0 #, python-format -msgid "" -"Last operation of transfer {}. Next operation ({}) is ready to proceed." +msgid "Last operation of transfer {}. Next operation ({}) is ready to proceed." msgstr "" #. module: shopfloor @@ -464,12 +481,18 @@ msgstr "" msgid "No valid package to select." msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No value" +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?" +"Not all lines have been processed with full quantity. Do you confirm partial " +"operation?" msgstr "" #. module: shopfloor @@ -593,6 +616,12 @@ msgstr "" msgid "Packages" msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging '{}' is not allowed for carrier {}." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -602,7 +631,19 @@ msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format -msgid "Packaging {} is not allowed for carrier {}." +msgid "" +"Pick + Pack mode ON: the picking {0.name} has no carrier set. The system " +"couldn't pack goods automatically." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time_is_possible +msgid "Pick Pack Same Time Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "Pick and pack at the same time" msgstr "" #. module: shopfloor @@ -667,12 +708,6 @@ msgstr "" 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 @@ -742,7 +777,6 @@ 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 "" @@ -785,8 +819,7 @@ msgstr "" #: code:addons/shopfloor/services/checkout.py:0 #, python-format msgid "" -"Several transfers found, please scan a package or select a transfer " -"manually." +"Several transfers found, please scan a package or select a transfer manually." msgstr "" #. module: shopfloor @@ -853,9 +886,9 @@ 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." +"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 @@ -930,8 +963,8 @@ msgstr "" #: code:addons/shopfloor/models/stock_move_line.py:0 #, python-format msgid "" -"The backorder %s has been created." +"The backorder %s has been created." msgstr "" #. module: shopfloor @@ -969,8 +1002,8 @@ msgstr "" 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 " +"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 "" @@ -1056,8 +1089,7 @@ msgstr "" #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "" -"This product is part of a package with other products, please scan a " -"package." +"This product is part of a package with other products, please scan a package." msgstr "" #. module: shopfloor diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 9f39bd0302b..76c4fc07154 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -16,6 +16,18 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "X-Generator: Weblate 4.3.2\n" +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "" +"\n" +"If you tick this box, while picking goods from a location\n" +"(eg: zone picking) set destination will work as follow:\n" +"\n" +"* if a location is scanned, a new delivery package is created;\n" +"* if a package is scanned, the package is validated against the carrier\n" +"* in both cases, if the picking has no carrier the operation fails.\",\n" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -200,6 +212,12 @@ msgstr "" msgid "From" msgstr "Desde" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Goods packed into {0.name}" +msgstr "" + #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id @@ -479,6 +497,12 @@ msgstr "" msgid "No valid package to select." msgstr "No hay paquete válido para seleccionar." +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No value" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -615,6 +639,12 @@ msgstr "El Paquete {} está reemplazado por el paquete {}." msgid "Packages" msgstr "Paquetes" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging '{}' is not allowed for carrier {}." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -624,8 +654,20 @@ msgstr "El Empaquetado cambió en el paquete {}" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format -msgid "Packaging {} is not allowed for carrier {}." -msgstr "Empaquetado {} no está permitido para en transportista {}." +msgid "" +"Pick + Pack mode ON: the picking {0.name} has no carrier set. The system " +"couldn't pack goods automatically." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time_is_possible +msgid "Pick Pack Same Time Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "Pick and pack at the same time" +msgstr "" #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 @@ -691,12 +733,6 @@ msgstr "Producto rastreado por lote, por favor escanée uno." msgid "Product {} belongs to a picking without a valid state." msgstr "Producto {} pertenece a una entrega sin estado válido." -#. module: shopfloor -#: code:addons/shopfloor/services/checkout.py:0 -#, python-format -msgid "Goods packed in {}" -msgstr "Producto(s) empaquetado(s) en {}" - #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -1234,6 +1270,12 @@ msgstr "{} no es un paquete de destino válido." msgid "{} {} put in {}" msgstr "{} {} poner en {}" +#~ msgid "Packaging {} is not allowed for carrier {}." +#~ msgstr "Empaquetado {} no está permitido para en transportista {}." + +#~ msgid "Goods packed in {}" +#~ msgstr "Producto(s) empaquetado(s) en {}" + #~ msgid "Not a valid destination package" #~ msgstr "No es un paquete de destino válido" From a64ed24f9c0abf3b036fb8a8542232a576fd62d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 27 Apr 2021 12:19:59 +0200 Subject: [PATCH 595/710] [FIX] sf, loc. content transfer: put lines in separate transfer (test) 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-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 in any case - as such the app should ask the destination location (as there is only one line) move1 PACK-1 -> SHIP (still handled by the operator so not 'done') 3) Operator-1 processes the second pallet with the "zone picking" scenario to move the goods to PACK-2: move1 PICK -> PACK-2 'done' - this will automatically update the reservation (new move line) in the transfer previously processed by Operator-2. 4) Operator-2 then finishes its operation regarding the first pallet without any trouble. 5) Operator-2 with the "location content transfer" scenario scan the location where the second pallet is (PACK-2), etc --- .../test_location_content_transfer_mix.py | 152 ++++++++++++++++-- 1 file changed, 142 insertions(+), 10 deletions(-) diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py index acc4704c49b..d5e2377f6dc 100644 --- a/shopfloor/tests/test_location_content_transfer_mix.py +++ b/shopfloor/tests/test_location_content_transfer_mix.py @@ -197,8 +197,12 @@ def test_with_zone_picking1(self): """ picking = self.picking1 move_lines = picking.move_line_ids - pick_move_line1 = move_lines[0] - pick_move_line2 = move_lines[1] + pick_move_line1 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_1 + ) + pick_move_line2 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_2 + ) # 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 @@ -258,11 +262,15 @@ def test_with_zone_picking2(self): only this pallet available. """ move_lines = self.picking1.move_line_ids - pick_move_line1 = move_lines[0] - pick_move_line2 = move_lines[1] + pick_move_line1 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_1 + ) + pick_move_line2 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_2 + ) # Operator-1 process the first pallet with the "zone picking" scenario - orig_dest_location = pick_move_line2.location_dest_id - dest_location1 = pick_move_line2.location_dest_id.sudo().copy( + orig_dest_location = pick_move_line1.location_dest_id + dest_location1 = pick_move_line1.location_dest_id.sudo().copy( { "name": orig_dest_location.name + "_1", "barcode": orig_dest_location.barcode + "_1", @@ -286,12 +294,12 @@ def test_with_zone_picking2(self): 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.product_uom_qty, 6) 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.product_uom_qty, 4) self.assertEqual(pack_second_pallet.qty_done, 0) # Operator-2 with the "location content transfer" scenario scan # the location where the first pallet is. @@ -337,7 +345,7 @@ def test_with_zone_picking2(self): pack_first_pallet.location_dest_id, ), ) - self.assertEqual(pack_first_pallet.qty_done, 4) + self.assertEqual(pack_first_pallet.qty_done, 6) 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 @@ -362,5 +370,129 @@ def test_with_zone_picking2(self): 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.assertEqual(picking_before, picking_after) self.assert_response_scan_destination_all(response, picking_after) + + def test_with_zone_picking3(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-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 in any case + - as such the app should ask the destination location (as there is + only one line) + + move1 PACK-1 -> SHIP (still handled by the operator so not 'done') + + 3) Operator-1 processes the second pallet with the "zone picking" scenario + to move the goods to PACK-2: + + move1 PICK -> PACK-2 'done' + + - this will automatically update the reservation (new move line) in + the transfer previously processed by Operator-2. + + 4) Operator-2 then finishes its operation regarding the first pallet + without any trouble. + + 5) Operator-2 with the "location content transfer" scenario scan + the location where the second pallet is (PACK-2), etc + """ + move_lines = self.picking1.move_line_ids + pick_move_line1 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_1 + ) + pick_move_line2 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_2 + ) + orig_dest_location = pick_move_line1.location_dest_id + dest_location1 = pick_move_line1.location_dest_id.sudo().copy( + { + "name": orig_dest_location.name + "_1", + "barcode": orig_dest_location.barcode + "_1", + "location_id": orig_dest_location.id, + } + ) + dest_location2 = orig_dest_location.sudo().copy( + { + "name": orig_dest_location.name + "_2", + "barcode": orig_dest_location.barcode + "_2", + "location_id": orig_dest_location.id, + } + ) + # Operator-1 process the first pallet with the "zone picking" scenario + self._zone_picking_process_line(pick_move_line1, dest_location=dest_location1) + pack_move_a1 = pick_move_line1.move_id.move_dest_ids.filtered( + lambda m: m.move_line_ids.package_id == self.package_1 + ) + self.assertEqual(pack_move_a1, self.pack_move_a) + pack_first_pallet = pack_move_a1.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, 6) + self.assertEqual(pack_first_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 move and transfer by convenience + original_pack_transfer = pack_first_pallet.picking_id + response = self._location_content_transfer_process_line(pack_first_pallet) + new_pack_transfer = pack_first_pallet.picking_id + self.assertNotEqual(original_pack_transfer, new_pack_transfer) + self.assert_response_scan_destination_all(response, new_pack_transfer) + 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 + ) + # All pack lines have been processed until now, so the existing pack + # operation is now waiting goods from pick operation + self.assertEqual(original_pack_transfer.state, "waiting") + # Operator-1 process the second pallet with the "zone picking" scenario + self._zone_picking_process_line(pick_move_line2, dest_location=dest_location2) + pack_move_a2 = pick_move_line2.move_id.move_dest_ids.filtered( + lambda m: m.move_line_ids.package_id == self.package_2 + ) + pack_second_pallet = pack_move_a2.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, 4) + self.assertEqual(pack_second_pallet.qty_done, 0) + # The last action has updated the pack operation (new move line) in the + # transfer previously processed by Operator-2. + self.assertEqual(original_pack_transfer.state, "assigned") + self.assertIn(self.package_2, original_pack_transfer.move_line_ids.package_id) + # Operator-2 finishes its operation regarding the first pallet without + # any trouble as the processed move line has been put in its own + # move+transfer + 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, 6) + self.assertEqual(pack_first_pallet.state, "done") + self.assertEqual(pack_first_pallet.move_id.product_uom_qty, qty) + # Operator-2 with the "location content transfer" scenario scan + # the location where the second pallet is. + original_pack_transfer = pack_second_pallet.picking_id + response = self._location_content_transfer_process_line(pack_second_pallet) + new_pack_transfer = pack_second_pallet.picking_id + # Transfer hasn't been split as we were processing the last line/pallet + self.assertEqual(original_pack_transfer, new_pack_transfer) + self.assert_response_scan_destination_all(response, new_pack_transfer) + 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 + ) From 87adc2756268a0fe17c1bb610dff4b8c0c3eb066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 27 Apr 2021 12:23:15 +0200 Subject: [PATCH 596/710] [FIX] sf, loc. content transfer: put lines in separate transfer (fix) As soon as we scan a location with the location content transfer, put all move lines to process in its own move+transfer to ensure they won't be updated by new reservations (because of another user moving goods to this location in the middle of a location content transfer process). --- shopfloor/models/stock_move_line.py | 5 +- shopfloor/models/stock_picking.py | 52 ++++++++++++++++++- .../services/location_content_transfer.py | 16 ++++-- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py index f6105e4d555..8f4a79f0f67 100644 --- a/shopfloor/models/stock_move_line.py +++ b/shopfloor/models/stock_move_line.py @@ -74,8 +74,9 @@ def _split_pickings_from_source_location(self): 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 new_move: + 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) diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index 2394a79ce25..fe27b7b4eb4 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -1,6 +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 api, fields, models +from odoo import _, api, fields, models class StockPicking(models.Model): @@ -51,3 +51,53 @@ def _check_move_lines_map_quant_package(self, package): ): return False return super()._check_move_lines_map_quant_package(package) + + def split_assigned_move_lines(self, move_lines=None): + """Put all reserved quantities (move lines) in their own moves and transfer. + + As a result, the current transfer will contain only confirmed moves. + """ + self.ensure_one() + # Check in the picking all the moves which are partially available or confirmed + moves = self.move_lines.filtered( + lambda m: m.state in ("partially_available", "confirmed") + ) + # If one of these moves has an ancestor, split the moves + # then extract all the assigned moves in a new transfer. + # Indeed, a move without ancestor won't see its reserved qty changed + # automatically over time. + has_ancestors = bool( + moves.move_orig_ids.filtered(lambda m: m.state not in ("cancel", "done")) + ) + if not has_ancestors: + return self.id + # Get only transfers composed of moves assigned or confirmed + moves.split_other_move_lines(moves.move_line_ids) + # Put assigned moves related to processed move lines into a separate transfer + if move_lines: + assigned_moves = self.move_lines & move_lines.move_id + else: + assigned_moves = self.move_lines.filtered(lambda m: m.state == "assigned") + if assigned_moves == self.move_lines: + return self.id + new_picking = self.copy( + { + "name": "/", + "move_lines": [], + "move_line_ids": [], + "backorder_id": self.id, + } + ) + message = _( + 'The backorder %s has been created.' + ) % (new_picking.id, new_picking.name) + self.message_post(body=message) + assigned_moves.write({"picking_id": new_picking.id}) + assigned_moves.mapped("move_line_ids").write({"picking_id": new_picking.id}) + assigned_moves.move_line_ids.package_level_id.write( + {"picking_id": new_picking.id} + ) + assigned_moves._action_assign() + return new_picking.id diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index a5c1765c3b8..2bea64e5a03 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -317,12 +317,22 @@ def scan_location(self, barcode): ), } ) - # 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. + # 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() + # Ensure we process move lines related to transfers having only one source + # location among all their move lines. + # We need to put the unreserved qty into separate moves as a new move + # line could be created in the middle of the process. + new_picking_ids = [] + for picking in pickings: + # -> put move lines to process in their own move/transfer + new_picking_id = picking.split_assigned_move_lines(move_lines) + new_picking_ids.append(new_picking_id) + if new_picking_ids != pickings.ids: + pickings = pickings.browse(new_picking_ids) # If the following criteria are met: # - no move lines have been found From fb1f1b329b521be512b3b20453617b1050c8d5b9 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sat, 1 May 2021 17:25:25 +0000 Subject: [PATCH 597/710] shopfloor 13.0.4.7.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index bd75d9e74ad..198b0c60e6e 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.4.7.0", + "version": "13.0.4.7.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 5efb980a655ce16d2b4f109a21edeba552313efc Mon Sep 17 00:00:00 2001 From: oca-travis Date: Sat, 1 May 2021 18:53:27 +0000 Subject: [PATCH 598/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 2a75749cce9..6aca71f5720 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -961,6 +961,7 @@ msgstr "" #. module: shopfloor #: code:addons/shopfloor/models/stock_move_line.py:0 +#: code:addons/shopfloor/models/stock_picking.py:0 #, python-format msgid "" "The backorder Date: Sat, 1 May 2021 18:53:57 +0000 Subject: [PATCH 599/710] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/ --- shopfloor/i18n/ca.po | 1 + shopfloor/i18n/es_AR.po | 1 + 2 files changed, 2 insertions(+) diff --git a/shopfloor/i18n/ca.po b/shopfloor/i18n/ca.po index a47058a5e6a..dca242e9cd5 100644 --- a/shopfloor/i18n/ca.po +++ b/shopfloor/i18n/ca.po @@ -961,6 +961,7 @@ msgstr "" #. module: shopfloor #: code:addons/shopfloor/models/stock_move_line.py:0 +#: code:addons/shopfloor/models/stock_picking.py:0 #, python-format msgid "" "The backorder Date: Fri, 7 May 2021 14:18:17 +0200 Subject: [PATCH 600/710] 13.0 - shopfloor: Fix wrong message in set_destination --- shopfloor/services/zone_picking.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 876cb3d00c0..02c4ddb7c6e 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -693,6 +693,7 @@ def _set_move_line_as_done(self, move_line, quantity, package, user=None): # the field ``shopfloor_user_id`` is updated with the current user move_line.shopfloor_user_id = user or self.env.user + # flake8: noqa: C901 def set_destination( self, move_line_id, barcode, quantity, confirmation=False, ): @@ -771,7 +772,10 @@ def set_destination( ) if response: if extra_message: - response["message"]["body"] += "\n" + extra_message["body"] + if response.get("message"): + response["message"]["body"] += "\n" + extra_message["body"] + else: + response["message"] = extra_message return response # When the barcode is a package From 1d224224077923f4e5c5598238a51ef0d0c05029 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 18 May 2021 08:10:04 +0200 Subject: [PATCH 601/710] shopfloor: get rid of shopfloor.log This feature has been migrated ages ago to `rest_log` + `shopfloor_log`. It's pointless to keep this model here. --- shopfloor/__manifest__.py | 1 - .../migrations/13.0.1.2.0/post-migration.py | 64 ------------------- shopfloor/models/__init__.py | 1 - shopfloor/models/shopfloor_log.py | 41 ------------ shopfloor/security/ir.model.access.csv | 2 - 5 files changed, 109 deletions(-) delete mode 100644 shopfloor/migrations/13.0.1.2.0/post-migration.py delete mode 100644 shopfloor/models/shopfloor_log.py delete mode 100644 shopfloor/security/ir.model.access.csv diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 198b0c60e6e..dc85d76ab13 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -49,7 +49,6 @@ "data": [ "data/shopfloor_scenario_data.xml", "security/groups.xml", - "security/ir.model.access.csv", "views/shopfloor_menu.xml", "views/stock_picking_type.xml", "views/stock_location.xml", diff --git a/shopfloor/migrations/13.0.1.2.0/post-migration.py b/shopfloor/migrations/13.0.1.2.0/post-migration.py deleted file mode 100644 index e7f946fbcb8..00000000000 --- a/shopfloor/migrations/13.0.1.2.0/post-migration.py +++ /dev/null @@ -1,64 +0,0 @@ -# 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: - exception_details = _get_exception_details(entry) - if exception_details: - new_vals.update(exception_details) - 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/__init__.py b/shopfloor/models/__init__.py index 4da30d37885..2f3414a52fd 100644 --- a/shopfloor/models/__init__.py +++ b/shopfloor/models/__init__.py @@ -1,6 +1,5 @@ from . import priority_postpone_mixin from . import shopfloor_menu -from . import shopfloor_log from . import stock_picking_type from . import stock_inventory from . import stock_location diff --git a/shopfloor/models/shopfloor_log.py b/shopfloor/models/shopfloor_log.py deleted file mode 100644 index 4f57a594bc7..00000000000 --- a/shopfloor/models/shopfloor_log.py +++ /dev/null @@ -1,41 +0,0 @@ -# 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 - -# TODO: drop this model on next release. -# The feature has been moved to `rest_log`. -# -# It has been tried several times -# to drop the class altogether w/ its related records in this module -# but the ORM is uncapable do delete many records directly relate to the model -# (selection fields values, server actions for crons, etc.) -# and the upgrade will be always broken -# because the model is not in the registry anymore. -# -# Example of the error: -# [2021-02-01 08:44:52,103 1 INFO odoodb ]odoo.addons.base.models.ir_model: -# Deleting 2617@ir.model.fields.selection -# (shopfloor.selection__shopfloor_log__severity__severe) -# 2021-02-01 08:44:52,107 1 WARNING odoodb odoo.modules.loading: -# Transient module states were reset -# 2021-02-01 08:44:52,110 1 ERROR odoodb odoo.modules.registry: -# Failed to load registry -# Traceback (most recent call last): -# File "/odoo/src/odoo/modules/registry.py", line 86, in new -# odoo.modules.load_modules(registry._db, force_demo, status, update_module) -# File "/odoo/src/odoo/modules/loading.py", line 472, in load_modules -# env['ir.model.data']._process_end(processed_modules) -# File "/odoo/src/odoo/addons/base/models/ir_model.py", line 2012, in _process_end -# record.unlink() -# File "/odoo/src/odoo/addons/base/models/ir_model.py", line 1206, in unlink -# not self.env[selection.field_id.model]._abstract: -# File "/odoo/src/odoo/api.py", line 463, in __getitem__ -# return self.registry[model_name]._browse(self, (), ()) -# File "/odoo/src/odoo/modules/registry.py", line 177, in __getitem__ -# return self.models[model_name] -# KeyError: 'shopfloor.log' - - -class ShopfloorLog(models.Model): - _name = "shopfloor.log" - _description = "Legacy model for tracking REST calls: replacedy by rest.log" diff --git a/shopfloor/security/ir.model.access.csv b/shopfloor/security/ir.model.access.csv deleted file mode 100644 index b4273b2dda3..00000000000 --- a/shopfloor/security/ir.model.access.csv +++ /dev/null @@ -1,2 +0,0 @@ -"id","name","model_id/id","group_id/id","perm_read","perm_write","perm_create","perm_unlink" -"access_shopfloor_log","access_shopfloor_log","model_shopfloor_log","base.group_user",1,0,0,0 From 3a228d914989d84e77cf21567d2a320a905831d4 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Tue, 18 May 2021 06:48:44 +0000 Subject: [PATCH 602/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 6aca71f5720..94979373b1a 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -154,11 +154,6 @@ msgstr "" msgid "Control stock issue in location {} for {}" msgstr "" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_uid -msgid "Created by" -msgstr "" - #. module: shopfloor #: code:addons/shopfloor/models/stock_move.py:0 #, python-format @@ -167,11 +162,6 @@ msgid "" "id=%d>%s." msgstr "" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_date -msgid "Created on" -msgstr "" - #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 #, python-format @@ -186,7 +176,6 @@ 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_priority_postpone_mixin__display_name msgid "Display Name" msgstr "" @@ -211,7 +200,6 @@ msgid "Goods packed into {0.name}" msgstr "" #. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id msgid "ID" msgstr "" @@ -258,21 +246,10 @@ 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_priority_postpone_mixin____last_update msgid "Last Modified on" msgstr "" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_uid -msgid "Last Updated by" -msgstr "" - -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_date -msgid "Last Updated on" -msgstr "" - #. module: shopfloor #: code:addons/shopfloor/actions/completion_info.py:0 #, python-format @@ -280,11 +257,6 @@ msgid "" "Last operation of transfer {}. Next operation ({}) is ready to proceed." msgstr "" -#. module: shopfloor -#: model:ir.model,name:shopfloor.model_shopfloor_log -msgid "Legacy model for tracking REST calls: replacedy by rest.log" -msgstr "" - #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format From ffae742a83a962659959dda52db5bcd33a24b5db Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 18 May 2021 07:04:20 +0000 Subject: [PATCH 603/710] shopfloor 13.0.4.8.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index dc85d76ab13..c6f544a25d3 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.4.7.1", + "version": "13.0.4.8.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 7bc36ad9d15bd8b4f1c31138476dbfa80e1469a8 Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Tue, 18 May 2021 07:04:31 +0000 Subject: [PATCH 604/710] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/ --- shopfloor/i18n/ca.po | 28 -------------------------- shopfloor/i18n/es_AR.po | 44 +++++++++++++++-------------------------- 2 files changed, 16 insertions(+), 56 deletions(-) diff --git a/shopfloor/i18n/ca.po b/shopfloor/i18n/ca.po index dca242e9cd5..1467ea7eb9c 100644 --- a/shopfloor/i18n/ca.po +++ b/shopfloor/i18n/ca.po @@ -157,11 +157,6 @@ msgstr "" msgid "Control stock issue in location {} for {}" msgstr "" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_uid -msgid "Created by" -msgstr "" - #. module: shopfloor #: code:addons/shopfloor/models/stock_move.py:0 #, python-format @@ -170,11 +165,6 @@ msgid "" "%s." msgstr "" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_date -msgid "Created on" -msgstr "" - #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 #, python-format @@ -189,7 +179,6 @@ 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_priority_postpone_mixin__display_name msgid "Display Name" msgstr "" @@ -214,7 +203,6 @@ msgid "Goods packed into {0.name}" msgstr "" #. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id msgid "ID" msgstr "" @@ -261,32 +249,16 @@ 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_priority_postpone_mixin____last_update msgid "Last Modified on" msgstr "" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_uid -msgid "Last Updated by" -msgstr "" - -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__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 -#: model:ir.model,name:shopfloor.model_shopfloor_log -msgid "Legacy model for tracking REST calls: replacedy by rest.log" -msgstr "" - #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 83790ef17aa..22f076b760b 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -157,11 +157,6 @@ msgstr "Transferencia de contenido desde {} hacia {}." msgid "Control stock issue in location {} for {}" msgstr "Error en control de inventario en ubicación {} para {}" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_uid -msgid "Created by" -msgstr "Creado por" - #. module: shopfloor #: code:addons/shopfloor/models/stock_move.py:0 #, python-format @@ -172,11 +167,6 @@ msgstr "" "Creado desde pedido pendiente %s." -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__create_date -msgid "Created on" -msgstr "Creado el" - #. module: shopfloor #: code:addons/shopfloor/models/shopfloor_menu.py:0 #, python-format @@ -191,7 +181,6 @@ msgid "Delivery" msgstr "Entrega" #. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__display_name #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name msgid "Display Name" msgstr "Mostrar Nombre" @@ -219,7 +208,6 @@ msgid "Goods packed into {0.name}" msgstr "" #. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__id #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id msgid "ID" msgstr "ID" @@ -271,21 +259,10 @@ msgid "Inventory Locations" msgstr "Ubicaciones de Inventario" #. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log____last_update #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update msgid "Last Modified on" msgstr "Última Modificación el" -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_uid -msgid "Last Updated by" -msgstr "Última Actualización por" - -#. module: shopfloor -#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_log__write_date -msgid "Last Updated on" -msgstr "Última Actualización el" - #. module: shopfloor #: code:addons/shopfloor/actions/completion_info.py:0 #, python-format @@ -294,11 +271,6 @@ msgstr "" "Última operación de transferencia: {}. Siguiente operación ({}) está lista " "para proceder." -#. module: shopfloor -#: model:ir.model,name:shopfloor.model_shopfloor_log -msgid "Legacy model for tracking REST calls: replacedy by rest.log" -msgstr "Modelo heredado para rastrear llamadas REST: reemplazado por rest.log" - #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -1271,6 +1243,22 @@ msgstr "{} no es un paquete de destino válido." msgid "{} {} put in {}" msgstr "{} {} poner en {}" +#~ msgid "Created by" +#~ msgstr "Creado por" + +#~ msgid "Created on" +#~ msgstr "Creado el" + +#~ msgid "Last Updated by" +#~ msgstr "Última Actualización por" + +#~ msgid "Last Updated on" +#~ msgstr "Última Actualización el" + +#~ msgid "Legacy model for tracking REST calls: replacedy by rest.log" +#~ msgstr "" +#~ "Modelo heredado para rastrear llamadas REST: reemplazado por rest.log" + #~ msgid "Packaging {} is not allowed for carrier {}." #~ msgstr "Empaquetado {} no está permitido para en transportista {}." From ea088a296c91846ff36eddcf9e6e59c410886826 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Wed, 26 May 2021 15:41:37 +0200 Subject: [PATCH 605/710] Add easy pack change in zone picking (shopfloor) On the zone picking scenario it is possible to select a move line by scanning the pack assigned to the move line. But for some situation (block storage) where a location contains multiple pack of the same type it may be difficult to scan the exact pack, and much easier to scan an identical one in front of the user. This change allows for this so when scanning a pack that can replace a pack in the existing move line, the user is offered the option to do so, by scanning the same pack a 2nd time. --- shopfloor/actions/message.py | 15 ++++ shopfloor/docs/zone_picking_diag_seq.plantuml | 1 + shopfloor/docs/zone_picking_diag_seq.png | Bin 171913 -> 179851 bytes shopfloor/services/zone_picking.py | 60 ++++++++++++---- shopfloor/tests/test_zone_picking_base.py | 4 ++ .../tests/test_zone_picking_select_line.py | 66 +++++++++++++++++- 6 files changed, 131 insertions(+), 15 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index bdf8c6978cd..238696a4d88 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -26,6 +26,12 @@ def package_not_found_for_barcode(self, barcode): "body": _("The package %s doesn't exist") % barcode, } + def package_has_no_product_to_take(self, barcode): + return { + "message_type": "error", + "body": _("The package %s doesn't contain any product to take.") % barcode, + } + def bin_not_found_for_barcode(self, barcode): return {"message_type": "error", "body": _("Bin %s doesn't exist") % barcode} @@ -72,6 +78,15 @@ def package_not_found(self): "body": _("This package does not exist anymore."), } + def package_different_change(self): + return { + "message_type": "warning", + "body": _( + "You scanned a different package with the same product, " + "do you want to change pack? Scan it again to confirm" + ), + } + def package_not_available_in_picking(self, package, picking): return { "message_type": "warning", diff --git a/shopfloor/docs/zone_picking_diag_seq.plantuml b/shopfloor/docs/zone_picking_diag_seq.plantuml index 712b1040943..1d859d93465 100644 --- a/shopfloor/docs/zone_picking_diag_seq.plantuml +++ b/shopfloor/docs/zone_picking_diag_seq.plantuml @@ -32,6 +32,7 @@ start -> select_picking_type: **/scan_location** select_picking_type -> select_line: **/list_move_lines** == /scan_source == +select_line -> select_line: **/scan_source** \n(scanned package not expected but valid, confirmation required) select_line -> set_line_destination: **/scan_source** == /set_destination == diff --git a/shopfloor/docs/zone_picking_diag_seq.png b/shopfloor/docs/zone_picking_diag_seq.png index 2e5ea4d70fbda52febcab4b320cad8f911a77ad3..0639ce4c61469395e3655c8c4d64c036ed4aa540 100644 GIT binary patch literal 179851 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ª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 bQrM??uO)K1QeP!)LvEO0DlI_l&&>V@Q-sco diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 02c4ddb7c6e..7b70cfbcc86 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -43,7 +43,10 @@ class ZonePicking(Component): * 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 + * package, if it is linked to a move line. If the package is not linked + to an existing move line but can be a replacement for one, the view is + updated to show only the fitting move lines. And the user can confirm + the change of package by scanning it a second time. * product * lot @@ -171,12 +174,15 @@ def _response_for_select_picking_type( message=message, ) - def _response_for_select_line(self, move_lines, message=None, popup=None): + def _response_for_select_line( + self, move_lines, message=None, popup=None, confirmation_required=False + ): + if confirmation_required and not message: + message = self.msg_store.need_confirmation() + data = self._data_for_move_lines(move_lines) + data["confirmation_required"] = confirmation_required return self._response( - next_state="select_line", - data=self._data_for_move_lines(move_lines), - message=message, - popup=popup, + next_state="select_line", data=data, message=message, popup=popup, ) def _response_for_set_line_destination( @@ -427,7 +433,7 @@ def _list_move_lines(self, location): move_lines = self._find_location_move_lines(location) return self._response_for_select_line(move_lines) - def _scan_source_location(self, barcode): + def _scan_source_location(self, barcode, confirmation=False): """Search a location and find available lines into it. """ response = None @@ -469,8 +475,12 @@ def _find_product_in_location(self, location): package = quants.package_id return product, lot, package - def _scan_source_package(self, barcode): + def _scan_source_package(self, barcode, confirmation=False): """Search a package and find available lines for it. + + Fist search for lines that have the specific package. + If none are found search for lines whose package could be replaced + by the one selected and in that case ask for confirmation. """ message = None response = None @@ -481,12 +491,34 @@ def _scan_source_package(self, barcode): move_lines = self._find_location_move_lines(package=package) if move_lines: response = self._response_for_set_line_destination(first(move_lines)) + return response, message + pack_location = package.location_id + if pack_location and pack_location.is_sublocation_of(self.zone_location): + # Check if the package selected can be a substitute on a move line + move_lines = self._find_location_move_lines( + locations=pack_location, + product=package.product_packaging_id.product_id, + ) + if move_lines: + if not confirmation: + message = self.msg_store.package_different_change() + response = self._response_for_select_line( + move_lines, message, confirmation_required=True + ) + else: + change_package_lot = self._actions_for("change.package.lot") + response = change_package_lot.change_package( + first(move_lines), + package, + self._response_for_set_line_destination, + self._response_for_change_pack_lot, + ) else: response = self._list_move_lines(self.zone_location) - message = self.msg_store.package_not_found() + message = self.msg_store.package_has_no_product_to_take(barcode) return response, message - def _scan_source_product(self, barcode): + def _scan_source_product(self, barcode, confirmation=False): """Search a product and find available lines for it. """ message = None @@ -503,7 +535,7 @@ def _scan_source_product(self, barcode): message = self.msg_store.product_not_found() return response, message - def _scan_source_lot(self, barcode): + def _scan_source_lot(self, barcode, confirmation=False): """Search a lot and find available lines for it. """ message = None @@ -520,7 +552,7 @@ def _scan_source_lot(self, barcode): message = self.msg_store.lot_not_found() return response, message - def scan_source(self, barcode): + def scan_source(self, barcode, confirmation=False): """Select a move line or narrow the list of move lines When the barcode is a location and we can unambiguously know which move @@ -540,6 +572,7 @@ def scan_source(self, barcode): * select_line: barcode not found or narrow the list on a location * set_line_destination: a line has been selected for picking """ + # select corresponding move line from barcode (location, package, product, lot) handlers = ( # search by location 1st @@ -552,7 +585,7 @@ def scan_source(self, barcode): self._scan_source_lot, ) for handler in handlers: - response, message = handler(barcode) + response, message = handler(barcode, confirmation=confirmation) if response: return self._response(base_response=response, message=message) response = self.list_move_lines() @@ -1328,6 +1361,7 @@ def list_move_lines(self): def scan_source(self): return { "barcode": {"required": False, "nullable": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, } def set_destination(self): diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index a4552109dc1..51ac41218ac 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -299,11 +299,13 @@ def _assert_response_select_line( move_lines, message=None, popup=None, + confirmation_required=False, ): 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), + "confirmation_required": confirmation_required, } for data_move_line in data["move_lines"]: move_line = self.env["stock.move.line"].browse(data_move_line["id"]) @@ -322,6 +324,7 @@ def assert_response_select_line( move_lines, message=None, popup=None, + confirmation_required=False, ): self._assert_response_select_line( "select_line", @@ -331,6 +334,7 @@ def assert_response_select_line( move_lines, message=message, popup=popup, + confirmation_required=confirmation_required, ) def _assert_response_set_line_destination( diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index e7555b61e4a..61323fef4e9 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -190,8 +190,24 @@ def test_scan_source_barcode_package_not_found(self): """Scan source: scanned package has no related move line, next step 'select_line' expected. """ + pack_code = self.free_package.name + response = self.service.dispatch("scan_source", params={"barcode": pack_code},) + move_lines = self.service._find_location_move_lines() + 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_has_no_product_to_take(pack_code), + ) + + def test_scan_source_barcode_package_not_exist(self): + """Scan source: scanned package that does not exist in the system + next step 'select_line' expected. + """ response = self.service.dispatch( - "scan_source", params={"barcode": self.free_package.name}, + "scan_source", params={"barcode": "P-Unknown"}, ) move_lines = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) @@ -200,8 +216,54 @@ def test_scan_source_barcode_package_not_found(self): zone_location=self.zone_location, picking_type=self.picking_type, move_lines=move_lines, - message=self.service.msg_store.package_not_found(), + message=self.service.msg_store.barcode_not_found(), + ) + + def test_scan_source_barcode_package_can_replace_in_line(self): + """Scan source: scanned package has no related line but can replace + next step 'select_line' expected with confirmation required set. + Scan source: 2nd time the package replace package line with new package + next step 'set_line_destination'. + """ + # Add the same product same package in the same location to use as replacement + picking1b = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves( + picking1b.move_lines, in_package=True, location=self.zone_sublocation1 + ) + picking1b.action_assign() + picking1b.action_cancel() + package1b = picking1b.package_level_ids[0].package_id + package1 = self.picking1.package_level_ids[0].package_id + # 1st scan + response = self.service.dispatch( + "scan_source", params={"barcode": package1b.name}, + ) + move_lines = self.service._find_location_move_lines(package=package1,) + 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_different_change(), + confirmation_required=True, + ) + self.assertEqual(self.picking1.package_level_ids[0].package_id, package1) + # 2nd scan + response = self.service.dispatch( + "scan_source", params={"barcode": package1b.name, "confirmation": True}, + ) + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_lines[0], + message=self.service.msg_store.package_replaced_by_package( + package1, package1b + ), ) + # Check the package has been changed on the move line + self.assertEqual(self.picking1.package_level_ids[0].package_id, package1b) def test_scan_source_barcode_product(self): """Scan source: scanned product has one related move line, From 213bf54df618c129abdcd8301aa980a4ebee074b Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 3 Jun 2021 10:47:00 +0000 Subject: [PATCH 606/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 94979373b1a..47cd13d5aad 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -964,6 +964,12 @@ msgstr "" msgid "The package %s cannot be transferred with this scenario." msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't contain any product to take." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -1154,6 +1160,14 @@ msgstr "" msgid "You must not pick more than {} units." msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"You scanned a different package with the same product, do you want to change" +" pack? Scan it again to confirm" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format From c8544502bae496b8487e49958b6841f0a1812f48 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 3 Jun 2021 11:01:57 +0000 Subject: [PATCH 607/710] shopfloor 13.0.4.9.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index c6f544a25d3..d8d36025757 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.4.8.0", + "version": "13.0.4.9.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 11641cd52477e7d15e1f2dea7c08fe4369938ea8 Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Thu, 3 Jun 2021 11:02:13 +0000 Subject: [PATCH 608/710] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/ --- shopfloor/i18n/ca.po | 14 ++++++++++++++ shopfloor/i18n/es_AR.po | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/shopfloor/i18n/ca.po b/shopfloor/i18n/ca.po index 1467ea7eb9c..6ded97489c9 100644 --- a/shopfloor/i18n/ca.po +++ b/shopfloor/i18n/ca.po @@ -964,6 +964,12 @@ msgstr "" msgid "The package %s cannot be transferred with this scenario." msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't contain any product to take." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -1153,6 +1159,14 @@ msgstr "" msgid "You must not pick more than {} units." msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"You scanned a different package with the same product, do you want to change " +"pack? Scan it again to confirm" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 22f076b760b..492b0989d10 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -1006,6 +1006,12 @@ msgstr "El paquete se ha movido, puede escanear un paquete nuevo." msgid "The package %s cannot be transferred with this scenario." msgstr "El paquete %s no puede ser transferido con este escenario." +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't contain any product to take." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -1202,6 +1208,14 @@ msgstr "No puede trabajar en el paquete (%s) fuera de la ubicación: %s" msgid "You must not pick more than {} units." msgstr "No debe seleccionar más de {} unidades." +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"You scanned a different package with the same product, do you want to change " +"pack? Scan it again to confirm" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format From 230eb6d5d1404fae62670479bc1a0ea5b61a6f7f Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Wed, 28 Apr 2021 16:30:41 +0200 Subject: [PATCH 609/710] Fix single pack transfer - check valid destination We ensure the destination is either valid regarding the picking destination location or the move destination location. With the push rules in the module stock_dynamic_routing in OCA/wms, it is possible that the move destination is not anymore a child of the picking default destination (as it is the last pushed move that now respects this condition and not anymore this one that has a destination to an intermediate location) --- shopfloor/README.rst | 1 + shopfloor/readme/CONTRIBUTORS.rst | 1 + shopfloor/services/checkout.py | 7 +- shopfloor/services/cluster_picking.py | 33 ++++----- .../services/location_content_transfer.py | 67 +++++++----------- shopfloor/services/service.py | 31 ++++++++ shopfloor/services/single_pack_transfer.py | 65 +++++++---------- shopfloor/services/zone_picking.py | 70 ++++++++----------- .../tests/test_cluster_picking_unload.py | 12 ++-- ...on_content_transfer_set_destination_all.py | 8 ++- ...ransfer_set_destination_package_or_line.py | 6 +- shopfloor/tests/test_single_pack_transfer.py | 31 ++++---- .../test_single_pack_transfer_putaway.py | 18 ++--- .../test_zone_picking_set_line_destination.py | 6 +- ...est_zone_picking_unload_set_destination.py | 1 + 15 files changed, 170 insertions(+), 187 deletions(-) diff --git a/shopfloor/README.rst b/shopfloor/README.rst index 1f0a9b8a1f5..fbbd202102c 100644 --- a/shopfloor/README.rst +++ b/shopfloor/README.rst @@ -105,6 +105,7 @@ Contributors * Alexandre Fayolle * Benoit Guillot * Thierry Ducrest +* Jacques-Etienne Baudoux Design ~~~~~~ diff --git a/shopfloor/readme/CONTRIBUTORS.rst b/shopfloor/readme/CONTRIBUTORS.rst index 4d4d24ed1bb..dae732196ee 100644 --- a/shopfloor/readme/CONTRIBUTORS.rst +++ b/shopfloor/readme/CONTRIBUTORS.rst @@ -4,6 +4,7 @@ * Alexandre Fayolle * Benoit Guillot * Thierry Ducrest +* Jacques-Etienne Baudoux Design ~~~~~~ diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 9084a7b0800..444b3753f7c 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -1,4 +1,5 @@ -# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from werkzeug.exceptions import BadRequest @@ -179,9 +180,7 @@ def scan_document(self, barcode): if not picking: location = search.location_from_scan(barcode) if location: - if not location.is_sublocation_of( - self.picking_types.mapped("default_location_src_id") - ): + if not self.is_src_location_valid(location): return self._response_for_select_document( message=self.msg_store.location_not_allowed() ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index ad27d20e97d..16209f88de2 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -1,4 +1,5 @@ -# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, fields from odoo.osv import expression @@ -934,24 +935,20 @@ def set_destination_all(self, picking_batch_id, barcode, confirmation=False): 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_all( batch, 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 - ): + if not self.is_dest_location_valid(lines.move_id, scanned_location): return self._response_for_unload_all( 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_confirm_unload_all(batch) + if not confirmation and self.is_dest_location_to_confirm( + first_line.location_dest_id, scanned_location + ): + return self._response_for_confirm_unload_all(batch) self._unload_write_destination_on_lines(lines, scanned_location) completion_info = self._actions_for("completion.info") @@ -1100,25 +1097,19 @@ def _unload_scan_destination_lines( # Lock move lines that will be updated 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) if not scanned_location: 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 - ): + if not self.is_dest_location_valid(lines.move_id, scanned_location): return self._response_for_unload_set_destination( batch, package, 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_confirm_unload_set_destination(batch, package) + if not confirmation and self.is_dest_location_to_confirm( + first_line.location_dest_id, scanned_location + ): + return self._response_for_confirm_unload_set_destination(batch, package) self._unload_write_destination_on_lines(lines, scanned_location) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 2bea64e5a03..e71d4c915a0 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -1,4 +1,5 @@ -# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _ @@ -203,7 +204,7 @@ def _create_moves_from_location(self, location): [("location_id", "=", location.id), ("quantity", ">", 0)] ) # create moves for each quant - picking_type = self.work.menu.picking_type_ids + picking_type = self.picking_types move_vals_list = [] for quant in quants: move_vals_list.append( @@ -338,16 +339,14 @@ def scan_location(self, barcode): # - no move lines have been found # - the menu is configured to allow the creation of moves # - the menu is bind to one picking type - # - scanned location is a child of the picking type source location + # - scanned location is a valid source for one the menu's picking types # then prepare new stock moves to move goods from the scanned location. menu = self.work.menu if ( not move_lines and menu.allow_move_create - and len(menu.picking_type_ids) == 1 - and location.is_sublocation_of( - menu.picking_type_ids.default_location_src_id - ) + and len(self.picking_types) == 1 + and self.is_src_location_valid(location) ): new_moves = self._create_moves_from_location(location) if not new_moves: @@ -364,8 +363,8 @@ def scan_location(self, barcode): pickings = new_moves.mapped("picking_id") move_lines = new_moves.move_line_ids for move_line in move_lines: - if not move_line.location_dest_id.is_sublocation_of( - menu.picking_type_ids.default_location_dest_id + if not self.is_dest_location_valid( + move_line.move_id, move_line.location_dest_id ): savepoint.rollback() @@ -463,19 +462,13 @@ def set_destination_all(self, location_id, barcode, confirmation=False): pickings, message=self.msg_store.barcode_not_found() ) - 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 - ): + if not self.is_dest_location_valid(move_lines.move_id, scanned_location): 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") + if not confirmation and self.is_dest_location_to_confirm( + move_lines.location_dest_id, scanned_location ): - # the scanned location is valid (child of picking type's destination) - # but not the expected one: ask for confirmation return self._response_for_scan_destination_all( pickings, confirmation_required=True ) @@ -652,26 +645,21 @@ def set_destination_package( return self._response_for_scan_destination( location, package_level, message=self.msg_store.no_location_found() ) - 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, - ): + package_moves = package_level.move_line_ids.move_id + if not self.is_dest_location_valid(package_moves, scanned_location): return self._response_for_scan_destination( location, package_level, message=self.msg_store.dest_location_not_allowed(), ) - if not scanned_location.is_sublocation_of(package_level.location_dest_id): - if not confirmation: - return self._response_for_scan_destination( - location, package_level, confirmation_required=True - ) + if not confirmation and self.is_dest_location_to_confirm( + package_level.location_dest_id, scanned_location + ): + return self._response_for_scan_destination( + 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 @@ -721,19 +709,16 @@ def set_destination_line( return self._response_for_scan_destination( location, move_line, message=self.msg_store.no_location_found() ) - 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 - ): + if not self.is_dest_location_valid(move_line.move_id, scanned_location): return self._response_for_scan_destination( location, move_line, message=self.msg_store.dest_location_not_allowed() ) - if not scanned_location.is_sublocation_of(move_line.location_dest_id): - if not confirmation: - return self._response_for_scan_destination( - location, move_line, confirmation_required=True - ) + if not confirmation and self.is_dest_location_to_confirm( + move_line.location_dest_id, scanned_location + ): + return self._response_for_scan_destination( + location, move_line, confirmation_required=True + ) self._lock_lines(move_line) diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index 4bb6dde6053..2d23ef35d37 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -1,5 +1,6 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # Copyright 2020 Akretion (http://www.akretion.com) +# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from werkzeug.exceptions import BadRequest @@ -281,3 +282,33 @@ def _check_picking_status(self, pickings): 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() + + def is_src_location_valid(self, location): + """Check the source location is valid for given process. + + We ensure the source is valid regarding one of the picking types of the + process. + """ + return location.is_sublocation_of(self.picking_types.default_location_src_id) + + def is_dest_location_valid(self, moves, location): + """Check the destination location is valid for given moves. + + We ensure the destination is either valid regarding the picking + destination location or the move destination location. With the push + rules in the module stock_dynamic_routing in OCA/wms, it is possible + that the move destination is not anymore a child of the picking default + destination (as it is the last pushed move that now respects this + condition and not anymore this one that has a destination to an + intermediate location) + """ + return location.is_sublocation_of( + moves.picking_id.location_dest_id, func=all + ) or location.is_sublocation_of(moves.location_dest_id, func=all) + + def is_dest_location_to_confirm(self, location_dest_id, location): + """Check the destination location requires confirmation + + The location is valid but not the expected one: ask for confirmation + """ + return not location.is_sublocation_of(location_dest_id) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 5cb696de37b..3c46d61a8a8 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -1,4 +1,5 @@ -# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) # Copyright 2020 Akretion (http://www.akretion.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import fields @@ -84,9 +85,7 @@ def start(self, barcode, confirmation=False): self.msg_store.package_not_found_for_barcode(barcode) ) - if not package.location_id.is_sublocation_of( - picking_types.mapped("default_location_src_id") - ): + if not self.is_src_location_valid(package.location_id): return self._response_for_start( message=self.msg_store.package_not_allowed_in_src_location( barcode, picking_types @@ -137,8 +136,8 @@ def start(self, barcode, confirmation=False): message = self.msg_store.no_pending_operation_for_pack(package) if not package_level and self.work.menu.allow_move_create: package_level = self._create_package_level(package) - if not package_level.location_dest_id.is_sublocation_of( - picking_types.default_location_dest_id + if not self.is_dest_location_valid( + package_level.move_line_ids.move_id, package_level.location_dest_id ): package_level = None savepoint.rollback() @@ -199,19 +198,8 @@ def _create_package_level(self, package): picking.action_assign() return package_level - 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 - ) 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""" - move_dest_location = move.move_line_ids[0].location_dest_id - return not scanned_location.is_sublocation_of(move_dest_location) + def _is_move_state_valid(self, moves): + return all(move.state != "cancel" for move in moves) def validate(self, package_level_id, location_barcode, confirmation=False): """Validate the transfer""" @@ -223,11 +211,11 @@ def validate(self, package_level_id, location_barcode, confirmation=False): message=self.msg_store.operation_not_found() ) - # 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): + # Do not use package_level.move_ids, this is only filled in when the + # moves have been created from a manually encoded package level, not + # when a package has been reserved for existing moves + moves = package_level.move_line_ids.move_id + if not self._is_move_state_valid(moves): return self._response_for_start( message=self.msg_store.operation_has_been_canceled_elsewhere() ) @@ -238,22 +226,23 @@ def validate(self, package_level_id, location_barcode, confirmation=False): package_level, message=self.msg_store.no_location_found() ) - if not self._is_dest_location_valid(move, scanned_location): + if not self.is_dest_location_valid(moves, scanned_location): return self._response_for_scan_location( package_level, message=self.msg_store.dest_location_not_allowed() ) - if self._is_dest_location_to_confirm(move, scanned_location): - if not confirmation: - 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 - ), - ) + if not confirmation and self.is_dest_location_to_confirm( + package_level.location_dest_id, scanned_location + ): + return self._response_for_scan_location( + package_level, + confirmation_required=True, + message=self.msg_store.confirm_location_changed( + package_level.location_dest_id, scanned_location + ), + ) - self._set_destination_and_done(move, scanned_location) + self._set_destination_and_done(package_level, scanned_location) return self._router_validate_success(package_level) def _is_last_move(self, move): @@ -270,12 +259,12 @@ def _router_validate_success(self, package_level): 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): + def _set_destination_and_done(self, package_level, 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 + package_level.location_dest_id = scanned_location stock = self._actions_for("stock") - stock.validate_moves(move) + stock.validate_moves(package_level.move_line_ids.move_id) 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 7b70cfbcc86..2feb75f206e 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -1,4 +1,5 @@ -# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import functools from collections import defaultdict @@ -407,9 +408,7 @@ def scan_location(self, barcode): 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") - ): + if not self.is_src_location_valid(zone_location): return self._response_for_start( message=self.msg_store.location_not_allowed() ) @@ -602,19 +601,14 @@ def _set_destination_location(self, move_line, quantity, confirmation, 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( - self.picking_type.default_location_dest_id - ) or not location.is_sublocation_of( - move_line.move_id.location_dest_id, func=all - ): + if not self.is_dest_location_valid(move_line.move_id, location): response = self._response_for_set_line_destination( move_line, message=self.msg_store.dest_location_not_allowed(), ) return (location_changed, response) - if ( - not location.is_sublocation_of(move_line.location_dest_id) - and not confirmation + if not confirmation and self.is_dest_location_to_confirm( + move_line.location_dest_id, location ): response = self._response_for_set_line_destination( move_line, @@ -1132,9 +1126,8 @@ def set_destination_all(self, barcode, confirmation=False): 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( - self.picking_type.default_location_dest_id - ): + moves = buffer_lines.mapped("move_id") + if not self.is_dest_location_valid(moves, location): error = self.msg_store.location_not_allowed() if error: return self._set_destination_all_response(buffer_lines, message=error) @@ -1143,19 +1136,19 @@ def set_destination_all(self, barcode, confirmation=False): # 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( - buffer_lines, - message=self.msg_store.confirm_location_changed( - first(buffer_lines.location_dest_id), location - ), - confirmation_required=True, - ) + if not confirmation and self.is_dest_location_to_confirm( + buffer_lines.location_dest_id, location + ): + return self._response_for_unload_all( + buffer_lines, + message=self.msg_store.confirm_location_changed( + 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) # 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 # TODO: update tests @@ -1285,11 +1278,8 @@ def unload_set_destination(self, package_id, barcode, confirmation=False): search = self._actions_for("search") location = search.location_from_scan(barcode) if location: - if not location.is_sublocation_of( - self.picking_type.default_location_dest_id - ) or not location.is_sublocation_of( - buffer_lines.move_id.location_dest_id, func=all - ): + moves = buffer_lines.mapped("move_id") + if not self.is_dest_location_valid(moves, location): return self._response_for_unload_set_destination( first(buffer_lines), message=self.msg_store.dest_location_not_allowed(), @@ -1299,19 +1289,19 @@ def unload_set_destination(self, package_id, barcode, confirmation=False): # 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( - first(buffer_lines), - message=self.msg_store.confirm_location_changed( - first(buffer_lines.location_dest_id), location - ), - confirmation_required=True, - ) + if not confirmation and self.is_dest_location_to_confirm( + buffer_lines.location_dest_id, location + ): + return self._response_for_unload_set_destination( + first(buffer_lines), + message=self.msg_store.confirm_location_changed( + 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) # 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: diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index d1b3de47e1c..68980b28518 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -351,13 +351,13 @@ def test_set_destination_all_error_location_invalid(self): 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 + It is invalid when the location is not a sublocation of the picking + or move destination """ 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 + move_lines[0].picking_id.location_dest_id = self.packing_a_location response = self.service.dispatch( "set_destination_all", @@ -754,10 +754,10 @@ def test_unload_scan_destination_error_location_invalid(self): 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 + It is invalid when the location is not a sublocation of the picking + or move destination """ - self.bin1_lines[0].move_id.location_dest_id = self.packing_a_location + self.bin1_lines[0].picking_id.location_dest_id = self.packing_a_location response = self.service.dispatch( "unload_scan_destination", params={ 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 73b0d8a6544..f881641fd03 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -289,10 +289,14 @@ def test_set_destination_all_dest_location_invalid(self): ) 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 + """The scanned destination location is not in the picking and 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 + self.pickings[0].location_dest_id = self.shelf1 response = self.service.dispatch( "set_destination_all", params={ 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 e090849a2f4..a74ab138d3b 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 @@ -116,13 +116,14 @@ def test_set_destination_package_dest_location_nok(self): ) def test_set_destination_package_dest_location_move_nok(self): - """Scanned destination location not valid (different as move)""" + """Scanned destination location not valid (different as move and picking)""" 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 + move.picking_id.location_dest_id = self.shelf1 response = self.service.dispatch( "set_destination_package", params={ @@ -299,12 +300,13 @@ def test_set_destination_line_dest_location_nok(self): ) def test_set_destination_line_dest_location_move_nok(self): - """Scanned destination location not valid (different as move)""" + """Scanned destination location not valid (different as picking and 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 + move_line.picking_id.location_dest_id = self.shelf1 response = self.service.dispatch( "set_destination_line", params={ diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 01464d82b77..9206cd5b30d 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -525,7 +525,8 @@ def test_validate_location_not_found(self): ) def test_validate_location_forbidden(self): - """Test a call on /validate on a forbidden location (not child of type) + """Test a call on /validate on a forbidden location (not child of + picking or move) The pre-conditions: @@ -536,7 +537,7 @@ def test_validate_location_forbidden(self): * 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 picking type used for the process + of the destination location of the picking used for the process """ # setup the picking as we need, like if the move line # was already started by the first step (start operation) @@ -558,8 +559,8 @@ 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) + def test_validate_location_move_not_child_of_picking_allowed(self): + """Test a call on /validate on a location not child of picking but child of move The pre-conditions: @@ -569,34 +570,32 @@ def test_validate_location_forbidden_move_invalid(self): * 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 + Note: the location is allowed when the move location has changed and + that location is 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 + location = package_level.location_dest_id.location_id + package_level.location_dest_id = location + package_level.move_line_ids.move_id.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"}, + next_state="start", + message={ + "message_type": "success", + "body": "The pack has been moved, you can scan a new pack.", + }, ) def test_validate_location_to_confirm(self): diff --git a/shopfloor/tests/test_single_pack_transfer_putaway.py b/shopfloor/tests/test_single_pack_transfer_putaway.py index caced595ff0..2860cde037d 100644 --- a/shopfloor/tests/test_single_pack_transfer_putaway.py +++ b/shopfloor/tests/test_single_pack_transfer_putaway.py @@ -77,9 +77,11 @@ def test_ignore_no_putaway_available(self): # no package level created to move the package self.assertFalse(package_levels) - def test_putaway_move_dest_not_child_of_picking_type_dest(self): + def test_putaway_move_dest_not_child_of_picking_dest(self): """Putaway is applied on move but the destination location is not a child of the default picking type destination location. + Case where the picking is created by scanning a package level. Then the + move destination is according to the putaway and valid. """ # Change the default destination location of the picking type # to get it outside of the putaway destination @@ -94,19 +96,7 @@ def test_putaway_move_dest_not_child_of_picking_type_dest(self): } ) # Check the result - existing_package_levels = self.env["stock.package_level"].search( - [("package_id", "=", self.package.id)] - ) response = self.service.dispatch( "start", params={"barcode": self.shelf1.barcode} ) - self.assert_response( - response, - next_state="start", - data=self.ANY, - message=self.service.msg_store.package_unable_to_transfer(self.package), - ) - current_package_levels = self.env["stock.package_level"].search( - [("package_id", "=", self.package.id)] - ) - self.assertEqual(existing_package_levels, current_package_levels) + self.assert_response(response, next_state="scan_location", data=self.ANY) diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index 5bd1a1a122a..9fa9bca9241 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -98,13 +98,13 @@ def test_set_destination_location_confirm(self): ) 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) + # Confirm the destination with a wrong destination, outside of picking + # and 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 + move_line.picking_id.location_dest_id = self.packing_sublocation_a response = self.service.dispatch( "set_destination", params={ diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index 9bcfc3dac61..0c18fc7cff1 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -97,6 +97,7 @@ def test_unload_set_destination_location_move_not_allowed(self): 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 + move_line[0].picking_id.location_dest_id = self.packing_sublocation_a # set the destination package self.service._set_destination_package( move_line, move_line.product_uom_qty, self.free_package, From ae5775ce14664e7f0f8367f8a30058f6e0c4b08d Mon Sep 17 00:00:00 2001 From: jabelchi Date: Fri, 4 Jun 2021 08:31:29 +0000 Subject: [PATCH 610/710] Translated using Weblate (Catalan) Currently translated at 14.2% (28 of 196 strings) Translation: wms-13.0/wms-13.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-13-0/wms-13-0-shopfloor/ca/ --- shopfloor/i18n/ca.po | 50 ++++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/shopfloor/i18n/ca.po b/shopfloor/i18n/ca.po index 6ded97489c9..7df7f08c43d 100644 --- a/shopfloor/i18n/ca.po +++ b/shopfloor/i18n/ca.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 13.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2021-04-13 11:46+0000\n" +"PO-Revision-Date: 2021-06-04 10:48+0000\n" "Last-Translator: jabelchi \n" "Language-Team: none\n" "Language: ca\n" @@ -43,7 +43,7 @@ msgstr "S'ha creat un inventari esborrany per a 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ó zero" #. module: shopfloor #: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin @@ -54,70 +54,70 @@ msgstr "" #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "All packages processed." -msgstr "" +msgstr "Processats tots els paquets." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create msgid "Allow Move Creation" -msgstr "" +msgstr "Permetre creació de moviment" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves msgid "Allow to process reserved quantities" -msgstr "" +msgstr "Permetre processar quantitats reservades" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Are you sure?" -msgstr "" +msgstr "Esteu segur?" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Barcode does not match with {}." -msgstr "" +msgstr "Codi de barres no coincideix amb {}." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Barcode not found" -msgstr "" +msgstr "Codi de barres no trobat" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_picking_batch msgid "Batch Transfer" -msgstr "" +msgstr "Transferència per lots" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Batch Transfer complete" -msgstr "" +msgstr "Transferència per lots completa" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Batch Transfer line done" -msgstr "" +msgstr "Línia de transferència per lots completada" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Bin %s doesn't exist" -msgstr "" +msgstr "Compartiment %s no existeix" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Canceled, you can scan a new pack." -msgstr "" +msgstr "Cancel·lat. No es pot escanejar un nou paquet." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Cannot change to lot {} which is entirely picked." -msgstr "" +msgstr "No es pot canviar al lot {} que ya està recollit per complet." #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_checkout @@ -137,25 +137,25 @@ msgstr "" #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Confirm location change from %s to %s?" -msgstr "" +msgstr "Confirmeu canvi d'ubicació de %s a %s?" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Content transfer to {} completed" -msgstr "" +msgstr "Transferència de contingut a {} competada" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Content transferred from {} to {}." -msgstr "" +msgstr "Contingut transferit de {} a {}." #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 #, python-format msgid "Control stock issue in location {} for {}" -msgstr "" +msgstr "Error de control d'estoc a la ubicació {} a {}" #. module: shopfloor #: code:addons/shopfloor/models/stock_move.py:0 @@ -164,24 +164,26 @@ msgid "" "Created from backorder " "%s." msgstr "" +"Creat des de comanda pendent %s." #. 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ó de moviments no està permesa pel menú {}." #. module: shopfloor #: model:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery #: model:shopfloor.scenario,name:shopfloor.scenario_delivery #: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo msgid "Delivery" -msgstr "" +msgstr "Lliurament" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name msgid "Display Name" -msgstr "" +msgstr "Nom a mostrar" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check @@ -194,7 +196,7 @@ msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id msgid "From" -msgstr "" +msgstr "Des de" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -205,7 +207,7 @@ msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id msgid "ID" -msgstr "" +msgstr "ID" #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available @@ -221,6 +223,8 @@ 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 marqueu aquesta casella, aquesta configuració permetrà al operador moure " +"mercaderies fins i tot si hi ha una reserva d'un altre tipus d'operació." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible From e6b9226ba05f349ea94b7cb93bf11f978e4bb06d Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 4 Jun 2021 12:18:31 +0000 Subject: [PATCH 611/710] [UPD] README.rst --- shopfloor/static/description/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html index 45ee1c99e84..f35d6ab5ff8 100644 --- a/shopfloor/static/description/index.html +++ b/shopfloor/static/description/index.html @@ -459,6 +459,7 @@

Contributors

  • Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
  • Benoit Guillot <benoit.guillot@akretion.com>
  • Thierry Ducrest <thierry.ducrest@camptocamp.com>
  • +
  • Jacques-Etienne Baudoux <je@bcim.be>
  • From 4d00fb22eec3e7e4b15bf0d0627d7239bc63885e Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 4 Jun 2021 12:18:32 +0000 Subject: [PATCH 612/710] shopfloor 13.0.4.10.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index d8d36025757..7fb3beeb0f8 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.4.9.0", + "version": "13.0.4.10.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From df9090c41c5848176fcfe5dcb3dd6658476b1a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 8 Jun 2021 15:14:17 +0200 Subject: [PATCH 613/710] [FIX] shopfloor: single pack transfer, handle backorders When processing a package with the single pack transfer scenario which belongs to a transfer having several other packages, the remaining ones are not put in a backorder and as such can not be processed anymore. This commit ensure to put the processed package/package level in its own move which will be validated in its own transfer, keeping the remaining packages to process in the current transfer. --- shopfloor/services/single_pack_transfer.py | 9 +- shopfloor/tests/test_single_pack_transfer.py | 150 +++++++++++++------ 2 files changed, 112 insertions(+), 47 deletions(-) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 3c46d61a8a8..3c9e528fe96 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -264,7 +264,14 @@ def _set_destination_and_done(self, package_level, scanned_location): # on the move lines package_level.location_dest_id = scanned_location stock = self._actions_for("stock") - stock.validate_moves(package_level.move_line_ids.move_id) + package_moves = package_level.move_line_ids.move_id + package_move_lines = package_level.move_line_ids + 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) + stock.validate_moves(package_moves) 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 9206cd5b30d..969c631d447 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -26,29 +26,50 @@ def setUpClassBaseData(cls, *args, **kwargs): } ) ) - cls.picking = cls._create_initial_move() + cls.shelf1_2 = cls.shelf1.sudo().copy() + cls.pack_b = cls.env["stock.quant.package"].create( + {"location_id": cls.stock_location.id} + ) + cls.quant_b = ( + cls.env["stock.quant"] + .sudo() + .create( + { + "product_id": cls.product_b.id, + "location_id": cls.shelf1_2.id, + "quantity": 1, + "package_id": cls.pack_b.id, + } + ) + ) + cls.picking = cls._create_initial_move( + lines=[(cls.product_a, 1), (cls.product_b, 1)] + ) @classmethod - def _create_initial_move(cls): + def _create_initial_move(cls, lines): """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_id = cls.stock_location 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 + for line in lines: + with picking_form.move_ids_without_package.new() as move: + move.product_id = line[0] + move.product_uom_qty = line[1] picking = picking_form.save() picking.action_confirm() picking.action_assign() return picking - def _simulate_started(self): - """Replicate what the /start endpoint would do + def _simulate_started(self, package): + """Replicate what the /start endpoint would do on the given package. Used to test the next endpoints (/validate and /cancel) """ - package_level = self.picking.move_line_ids.package_level_id + package_level = self.picking.move_line_ids.package_level_id.filtered( + lambda pl: pl.package_id == package + ) package_level.is_done = True return package_level @@ -83,7 +104,9 @@ def test_start(self): barcode = self.pack_a.name params = {"barcode": barcode} - package_level = self.picking.move_line_ids.package_level_id + package_level = self.picking.move_line_ids.package_level_id.filtered( + lambda pl: pl.package_id == self.pack_a + ) self.assertFalse(package_level.is_done) # Simulate the client scanning a package's barcode, which @@ -341,7 +364,7 @@ def test_start_already_started(self): barcode = self.pack_a.name params = {"barcode": barcode} - package_level = self._simulate_started() + package_level = self._simulate_started(self.pack_a) self.assertTrue(package_level.is_done) # Simulate the client scanning a package's barcode, which @@ -376,7 +399,7 @@ def test_validate(self): """ # 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() + package_level = self._simulate_started(self.pack_a) # now, call the service to proceed with validation of the # movement @@ -425,10 +448,6 @@ def test_validate_completion_info(self): * 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.sudo().display_completion_info = True @@ -446,17 +465,41 @@ def test_validate_completion_info(self): ) next_picking.action_confirm() - # now, call the service to proceed with validation of the - # movement + # process the first package + package_level_a = self._simulate_started(self.pack_a) + # validate the first package response = self.service.dispatch( "validate", params={ - "package_level_id": package_level.id, + "package_level_id": package_level_a.id, "location_barcode": self.shelf2.barcode, }, ) - self.assertEqual(package_level.picking_id.state, "done") - + self.assertEqual(package_level_a.picking_id.state, "done") + # check the response: still no completion info message as we still have + # the second package to process + self.assert_response( + response, + next_state="start", + message={ + "message_type": "success", + "body": "The pack has been moved, you can scan a new pack.", + }, + ) + # process the second package + package_level_b = self._simulate_started(self.pack_b) + # validate the second package + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level_b.id, + "location_barcode": self.shelf2.barcode, + }, + ) + self.assertEqual(package_level_b.picking_id.state, "done") + self.assertNotEqual(package_level_a.picking_id, package_level_b.picking_id) + # check the response: the chained transfer is ready to be processed now + # that all the packages have been processed self.assert_response( response, next_state="start", @@ -504,7 +547,7 @@ def test_validate_location_not_found(self): """ # 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() + package_level = self._simulate_started(self.pack_a) response = self.service.dispatch( "validate", @@ -541,7 +584,7 @@ def test_validate_location_forbidden(self): """ # 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() + package_level = self._simulate_started(self.pack_a) response = self.service.dispatch( "validate", @@ -575,7 +618,7 @@ def test_validate_location_move_not_child_of_picking_allowed(self): """ # 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() + package_level = self._simulate_started(self.pack_a) location = package_level.location_dest_id.location_id package_level.location_dest_id = location @@ -615,7 +658,7 @@ def test_validate_location_to_confirm(self): """ # 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() + package_level = self._simulate_started(self.pack_a) sub_shelf1 = ( self.env["stock.location"] @@ -685,7 +728,7 @@ def test_validate_location_with_confirm(self): """ # 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() + package_level = self._simulate_started(self.pack_a) # expected destination is 'shelf1', we'll scan shelf2 which must # ask a confirmation to the user (it's still in the same picking type) @@ -730,7 +773,7 @@ def test_cancel(self): """ # 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() + package_level = self._simulate_started(self.pack_a) self.assertTrue(package_level.is_done) # keep references for later checks @@ -768,24 +811,22 @@ def test_cancel_already_canceled(self): """ # 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() - + package_level_a = self._simulate_started(self.pack_a) # keep references for later checks - move = package_level.move_line_ids.move_id - move_lines = package_level.move_line_ids - picking = move.picking_id - + move_a = package_level_a.move_line_ids.move_id + move_lines_a = package_level_a.move_line_ids + picking = move_a.picking_id # someone cancel the work started by our operator - move._action_cancel() + move_a._action_cancel() - # now, call the service to cancel + # now, call the service to cancel the first package response = self.service.dispatch( - "cancel", params={"package_level_id": package_level.id} + "cancel", params={"package_level_id": package_level_a.id} ) - self.assertRecordValues(move, [{"state": "cancel"}]) - self.assertRecordValues(picking, [{"state": "cancel"}]) - self.assertFalse(package_level.move_line_ids) - self.assertFalse(move_lines.exists()) + self.assertRecordValues(move_a, [{"state": "cancel"}]) + self.assertRecordValues(picking, [{"state": "assigned"}]) + self.assertFalse(package_level_a.move_line_ids) + self.assertFalse(move_lines_a.exists()) self.assert_response( response, @@ -795,6 +836,18 @@ def test_cancel_already_canceled(self): "body": "Canceled, you can scan a new pack.", }, ) + package_level_b = self._simulate_started(self.pack_b) + # keep references for later checks + move_b = package_level_b.move_line_ids.move_id + # someone cancel the work started by our operator + move_b._action_cancel() + # then cancel the second package + response = self.service.dispatch( + "cancel", params={"package_level_id": package_level_b.id} + ) + self.assertRecordValues(move_b, [{"state": "cancel"}]) + picking.invalidate_cache(["state"]) + self.assertRecordValues(picking, [{"state": "cancel"}]) def test_cancel_already_done(self): """Test a call on /cancel on move already done @@ -810,20 +863,25 @@ def test_cancel_already_done(self): """ # 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() + package_levels = self._simulate_started(self.pack_a) | self._simulate_started( + self.pack_b + ) # keep references for later checks - move = package_level.move_line_ids.move_id - picking = move.picking_id + moves = package_levels.move_line_ids.move_id + picking = moves.picking_id # someone cancel the work started by our operator - move.extract_and_action_done() + moves.extract_and_action_done() # now, call the service to cancel response = self.service.dispatch( - "cancel", params={"package_level_id": package_level.id} + "cancel", params={"package_level_id": package_levels[0].id} + ) + response = self.service.dispatch( + "cancel", params={"package_level_id": package_levels[1].id} ) - self.assertRecordValues(move, [{"state": "done"}]) + self.assertRecordValues(moves, [{"state": "done"}, {"state": "done"}]) self.assertRecordValues(picking, [{"state": "done"}]) self.assert_response( From 5fc15b49e580cda315ba8eeb688f00c9c830a9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 8 Jun 2021 17:19:53 +0200 Subject: [PATCH 614/710] [IMP] shopfloor: new stock helper 'put_package_level_in_move' New method 'put_package_level_in_move' to put a package in its own move, making it easy to process afterward, without impacting other package levels. --- shopfloor/actions/stock.py | 17 +++++++++++++++++ shopfloor/services/location_content_transfer.py | 8 ++------ shopfloor/services/single_pack_transfer.py | 10 ++-------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/shopfloor/actions/stock.py b/shopfloor/actions/stock.py index 7c7d4d0dd3e..45f553f203f 100644 --- a/shopfloor/actions/stock.py +++ b/shopfloor/actions/stock.py @@ -43,3 +43,20 @@ def _check_backorder(self, picking, moves): moves.move_orig_ids.filtered(lambda m: m.state not in ("cancel", "done")) ) return moves == assigned_moves and not has_ancestors + + def put_package_level_in_move(self, package_level): + """Ensure to put the package level in its own move. + + In standard the moves linked to a package level could also be linked to + other unrelated move lines. This method ensures that the package level + will be attached to a move with only the relevant lines. + This is useful to process a single package, having its own move makes + this process easy. + """ + package_move_lines = package_level.move_line_ids + package_moves = package_move_lines.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) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index e71d4c915a0..e14a5fe3a27 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -660,13 +660,9 @@ def set_destination_package( ) package_move_lines = package_level.move_line_ids self._lock_lines(package_move_lines) - 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) - self._write_destination_on_lines(package_level.move_line_ids, scanned_location) stock = self._actions_for("stock") + stock.put_package_level_in_move(package_level) + self._write_destination_on_lines(package_level.move_line_ids, scanned_location) stock.validate_moves(package_moves) move_lines = self._find_transfer_move_lines(location) message = self.msg_store.location_content_transfer_item_complete( diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 3c9e528fe96..32e7489ee81 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -264,14 +264,8 @@ def _set_destination_and_done(self, package_level, scanned_location): # on the move lines package_level.location_dest_id = scanned_location stock = self._actions_for("stock") - package_moves = package_level.move_line_ids.move_id - package_move_lines = package_level.move_line_ids - 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) - stock.validate_moves(package_moves) + stock.put_package_level_in_move(package_level) + stock.validate_moves(package_level.move_line_ids.move_id) def cancel(self, package_level_id): package_level = self.env["stock.package_level"].browse(package_level_id) From a235f8a769abc49adc431600c90e1532b797bded Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 9 Jun 2021 06:30:39 +0000 Subject: [PATCH 615/710] shopfloor 13.0.4.10.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 7fb3beeb0f8..10d3d50a1a6 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.4.10.0", + "version": "13.0.4.10.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From f0c7f14207d01b4d105d58246dbc143e88fa6cbb Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 2 Jul 2021 09:11:01 +0000 Subject: [PATCH 616/710] shopfloor 13.0.4.10.2 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 10d3d50a1a6..55600fa8e1c 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.4.10.1", + "version": "13.0.4.10.2", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 10284e669c11a1f23873ce23966c0d5b74dc6b70 Mon Sep 17 00:00:00 2001 From: hparfr Date: Mon, 19 Jul 2021 16:15:11 +0200 Subject: [PATCH 617/710] shopfloor: mark as uninstallable --- shopfloor/__manifest__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 55600fa8e1c..80841d6852e 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -55,4 +55,5 @@ "views/stock_move_line.xml", ], "demo": ["demo/stock_picking_type_demo.xml", "demo/shopfloor_menu_demo.xml"], + "installable": False, } From dfc150507425c2c945b85b6cd6ea1ae5ae205741 Mon Sep 17 00:00:00 2001 From: hparfr Date: Tue, 1 Dec 2020 22:33:24 +0100 Subject: [PATCH 618/710] [MIG] shopfloor: Migration to 14.0 From v13, priority selection field has changed, date must be used instead of date_dealine on stock_move, some methods have been renamed, stock.move._do_unreserve dont unlink all move line but only the ones that have no qty_done --- shopfloor/__manifest__.py | 6 +- shopfloor/actions/data_detail.py | 2 +- shopfloor/actions/move_line_search.py | 10 +- shopfloor/actions/packaging.py | 2 +- shopfloor/actions/stock.py | 2 +- shopfloor/components/scan_handler_location.py | 3 +- shopfloor/components/scan_handler_lot.py | 3 +- shopfloor/components/scan_handler_package.py | 3 +- shopfloor/components/scan_handler_product.py | 3 +- shopfloor/components/scan_handler_transfer.py | 3 +- .../migrations/13.0.1.1.0/pre-migration.py | 79 ---------- shopfloor/models/stock_move.py | 7 +- shopfloor/models/stock_quant_package.py | 4 +- shopfloor/readme/CONTRIBUTORS.rst | 1 + shopfloor/services/checkout.py | 13 +- shopfloor/services/cluster_picking.py | 14 +- shopfloor/services/delivery.py | 8 +- .../services/location_content_transfer.py | 11 +- shopfloor/services/zone_picking.py | 88 +++++++---- shopfloor/tests/models.py | 8 +- .../tests/test_actions_change_package_lot.py | 2 +- shopfloor/tests/test_actions_data_base.py | 2 +- shopfloor/tests/test_actions_data_detail.py | 8 +- .../tests/test_checkout_change_packaging.py | 6 +- shopfloor/tests/test_checkout_scan.py | 5 +- .../test_checkout_scan_package_action.py | 14 +- shopfloor/tests/test_checkout_select.py | 4 +- .../test_checkout_select_package_base.py | 9 +- shopfloor/tests/test_checkout_set_qty.py | 4 +- shopfloor/tests/test_cluster_picking_batch.py | 4 +- .../tests/test_cluster_picking_select.py | 19 ++- .../tests/test_cluster_picking_unload.py | 4 +- shopfloor/tests/test_delivery_scan_deliver.py | 2 +- .../test_location_content_transfer_mix.py | 8 +- ...ransfer_set_destination_package_or_line.py | 4 +- shopfloor/tests/test_menu_base.py | 6 +- shopfloor/tests/test_menu_counters.py | 4 +- shopfloor/tests/test_single_pack_transfer.py | 3 + shopfloor/tests/test_zone_picking_base.py | 46 +++++- .../tests/test_zone_picking_select_line.py | 143 +++++++++++++----- .../test_zone_picking_select_picking_type.py | 5 +- .../test_zone_picking_set_line_destination.py | 8 +- ..._picking_set_line_destination_pick_pack.py | 18 +-- .../tests/test_zone_picking_stock_issue.py | 38 +++-- .../tests/test_zone_picking_unload_all.py | 92 ++++++++--- .../test_zone_picking_unload_buffer_lines.py | 12 +- ...est_zone_picking_unload_set_destination.py | 28 +++- .../tests/test_zone_picking_unload_single.py | 26 +++- .../tests/test_zone_picking_zero_check.py | 25 ++- 49 files changed, 503 insertions(+), 316 deletions(-) delete mode 100644 shopfloor/migrations/13.0.1.1.0/pre-migration.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 80841d6852e..9a8330b6917 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.4.10.2", + "version": "14.0.1.0.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", @@ -25,9 +25,9 @@ # OCA / stock-logistics-warehouse "stock_helper", "stock_picking_completion_info", - "stock_quant_package_product_packaging", # OCA / stock-logistics-workflow "stock_quant_package_dimension", + "stock_quant_package_product_packaging", # TODO: used for manuf info on prod detail. # This must be an optional dep "product_manufacturer", @@ -55,5 +55,5 @@ "views/stock_move_line.xml", ], "demo": ["demo/stock_picking_type_demo.xml", "demo/shopfloor_menu_demo.xml"], - "installable": False, + "installable": True, } diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py index 8523ca88b7c..2e4f871ae63 100644 --- a/shopfloor/actions/data_detail.py +++ b/shopfloor/actions/data_detail.py @@ -85,7 +85,7 @@ def lots_detail(self, record, **kw): def _lot_detail_parser(self): return self._lot_parser + [ "removal_date", - "life_date:expire_date", + "expiration_date:expire_date", ( "product_id:product", lambda record, fname: self.product_detail(record[fname]), diff --git a/shopfloor/actions/move_line_search.py b/shopfloor/actions/move_line_search.py index 2b5adbba5c9..f789720604f 100644 --- a/shopfloor/actions/move_line_search.py +++ b/shopfloor/actions/move_line_search.py @@ -92,21 +92,19 @@ def _sort_key_move_lines(order): # make prority negative to keep sorting ascending return lambda line: ( -int(line.move_id.priority or "0"), - line.move_id.date_expected, + line.move_id.date, ) elif order == "location": return lambda line: ( line.location_id.shopfloor_picking_sequence or "", line.location_id.name, - line.move_id.date_expected, + line.move_id.date, ) return lambda line: line - def counters_for_lines(self, lines, priority_selection=("2", "3")): + def counters_for_lines(self, lines): # 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 - ] + priority_lines = [x for x in lines if x.picking_id.priority == 1] return { "lines_count": len(lines), "picking_count": len({x.picking_id.id for x in lines}), diff --git a/shopfloor/actions/packaging.py b/shopfloor/actions/packaging.py index e2d4f139355..391484efca6 100644 --- a/shopfloor/actions/packaging.py +++ b/shopfloor/actions/packaging.py @@ -38,7 +38,7 @@ def create_package_from_packaging(self, packaging=None): def _package_vals_from_packaging(self, packaging): return { "packaging_id": packaging.id, - "lngth": packaging.lngth, + "pack_length": packaging.packaging_length, "width": packaging.width, "height": packaging.height, } diff --git a/shopfloor/actions/stock.py b/shopfloor/actions/stock.py index 45f553f203f..f5ed66b9e35 100644 --- a/shopfloor/actions/stock.py +++ b/shopfloor/actions/stock.py @@ -25,7 +25,7 @@ def validate_moves(self, moves): for picking in moves.picking_id: moves_todo = picking.move_lines & moves if self._check_backorder(picking, moves_todo): - picking.action_done() + picking._action_done() else: moves_todo.extract_and_action_done() diff --git a/shopfloor/components/scan_handler_location.py b/shopfloor/components/scan_handler_location.py index dae8b354c2c..94bc8745701 100644 --- a/shopfloor/components/scan_handler_location.py +++ b/shopfloor/components/scan_handler_location.py @@ -7,8 +7,7 @@ class LocationHandler(Component): - """Scan anything handler for stock.location. - """ + """Scan anything handler for stock.location.""" _name = "shopfloor.scan.location.handler" _inherit = "shopfloor.scan.anything.handler" diff --git a/shopfloor/components/scan_handler_lot.py b/shopfloor/components/scan_handler_lot.py index 95d816724f3..a1ea34857e2 100644 --- a/shopfloor/components/scan_handler_lot.py +++ b/shopfloor/components/scan_handler_lot.py @@ -7,8 +7,7 @@ class LotHandler(Component): - """Scan anything handler for stock.production.lot. - """ + """Scan anything handler for stock.production.lot.""" _name = "shopfloor.scan.lot.handler" _inherit = "shopfloor.scan.anything.handler" diff --git a/shopfloor/components/scan_handler_package.py b/shopfloor/components/scan_handler_package.py index ee0eff762aa..79c463de73e 100644 --- a/shopfloor/components/scan_handler_package.py +++ b/shopfloor/components/scan_handler_package.py @@ -7,8 +7,7 @@ class PackageHandler(Component): - """Scan anything handler for stock.quant.package. - """ + """Scan anything handler for stock.quant.package.""" _name = "shopfloor.scan.package.handler" _inherit = "shopfloor.scan.anything.handler" diff --git a/shopfloor/components/scan_handler_product.py b/shopfloor/components/scan_handler_product.py index ac06a933edf..1a56e0236e3 100644 --- a/shopfloor/components/scan_handler_product.py +++ b/shopfloor/components/scan_handler_product.py @@ -7,8 +7,7 @@ class ProductHandler(Component): - """Scan anything handler for product.product. - """ + """Scan anything handler for product.product.""" _name = "shopfloor.scan.product.handler" _inherit = "shopfloor.scan.anything.handler" diff --git a/shopfloor/components/scan_handler_transfer.py b/shopfloor/components/scan_handler_transfer.py index 3fc26092221..8a1dc285ba6 100644 --- a/shopfloor/components/scan_handler_transfer.py +++ b/shopfloor/components/scan_handler_transfer.py @@ -7,8 +7,7 @@ class TransferHandler(Component): - """Scan anything handler for stock.picking. - """ + """Scan anything handler for stock.picking.""" _name = "shopfloor.scan.transfer.handler" _inherit = "shopfloor.scan.anything.handler" diff --git a/shopfloor/migrations/13.0.1.1.0/pre-migration.py b/shopfloor/migrations/13.0.1.1.0/pre-migration.py deleted file mode 100644 index ef555c78c07..00000000000 --- a/shopfloor/migrations/13.0.1.1.0/pre-migration.py +++ /dev/null @@ -1,79 +0,0 @@ -# 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 - - -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/stock_move.py b/shopfloor/models/stock_move.py index 43bab66b062..3e480be2b7f 100644 --- a/shopfloor/models/stock_move.py +++ b/shopfloor/models/stock_move.py @@ -38,8 +38,9 @@ def split_other_move_lines(self, move_lines, intersection=False): 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_vals = self._split(qty_to_split) + backorder_move = self.create(backorder_move_vals) + backorder_move._action_confirm(merge=False) backorder_move.move_line_ids = other_move_lines backorder_move._recompute_state() backorder_move._action_assign() @@ -102,5 +103,5 @@ def extract_and_action_done(self): # hence the new picking must be assigned already. # DO NOT CALL `new_picking.action_assign` or you'll wipe qty_done. assert new_picking.state == "assigned" - new_picking.action_done() + new_picking._action_done() return True diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py index f54c4097c01..4d66a3e0b50 100644 --- a/shopfloor/models/stock_quant_package.py +++ b/shopfloor/models/stock_quant_package.py @@ -40,11 +40,11 @@ def _compute_reserved_move_lines(self): for rec in self: rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()}) - @api.depends("pack_weight", "estimated_pack_weight") + @api.depends("pack_weight", "estimated_pack_weight_kg") @api.depends_context("picking_id") def _compute_shopfloor_weight(self): for rec in self: - rec.shopfloor_weight = rec.pack_weight or rec.estimated_pack_weight + rec.shopfloor_weight = rec.pack_weight or rec.estimated_pack_weight_kg # TODO: we should refactor this like diff --git a/shopfloor/readme/CONTRIBUTORS.rst b/shopfloor/readme/CONTRIBUTORS.rst index dae732196ee..be30942b85c 100644 --- a/shopfloor/readme/CONTRIBUTORS.rst +++ b/shopfloor/readme/CONTRIBUTORS.rst @@ -4,6 +4,7 @@ * Alexandre Fayolle * Benoit Guillot * Thierry Ducrest +* Raphaël Reverdy * Jacques-Etienne Baudoux Design diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index 444b3753f7c..386e7a3201a 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -415,7 +415,7 @@ def _select_lines_from_product(self, picking, selection_lines, product): # 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: + if packages and len({line.package_id for line in lines}) > 1: return self._response_for_select_line( picking, message=self.msg_store.product_multiple_packages_scan_package() ) @@ -447,7 +447,7 @@ def _select_lines_from_lot(self, picking, selection_lines, lot): # package, but also if we have one lot as a package and the same lot as # a unit in another line. In both cases, we want the user to scan the # package. - if packages and len({l.package_id for l in lines}) > 1: + if packages and len({line.package_id for line in lines}) > 1: return self._response_for_select_line( picking, message=self.msg_store.lot_multiple_packages_scan_package() ) @@ -672,7 +672,8 @@ def _put_lines_in_allowed_package(self, picking, selected_lines, package): ) # go back to the screen to select the next lines to pack return self._response_for_select_line( - picking, message=self.msg_store.goods_packed_in(package), + picking, + message=self.msg_store.goods_packed_in(package), ) def _create_and_assign_new_packaging(self, picking, selected_lines, packaging=None): @@ -934,7 +935,9 @@ def set_dest_package(self, picking_id, selected_line_ids, package_id): package = self.env["stock.quant.package"].browse(package_id).exists() if not package: return self._response_for_select_dest_package( - picking, lines, message=self.msg_store.record_not_found(), + picking, + lines, + message=self.msg_store.record_not_found(), ) return self._set_dest_package_from_selection(picking, lines, package) @@ -1080,7 +1083,7 @@ def done(self, picking_id, confirmation=False): "body": _("Remaining raw product not packed, proceed anyway?"), }, ) - picking.action_done() + picking._action_done() return self._response_for_select_document( message=self.msg_store.transfer_done_success(picking) ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 16209f88de2..5c9897af408 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -332,8 +332,8 @@ def _sort_key_lines(line): line.shopfloor_priority or 10, line.location_id.shopfloor_picking_sequence or "", line.location_id.name, - -int(line.move_id.priority or 1), - line.move_id.date_expected, + -int(line.move_id.priority or 0), + line.move_id.date, line.move_id.sequence, line.move_id.id, line.id, @@ -490,7 +490,7 @@ def _scan_line_by_product(self, picking, move_line, product): # 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: + if packages and len({line.package_id for line in other_product_lines}) > 1: return self._response_for_start_line( move_line, message=self.msg_store.product_multiple_packages_scan_package(), @@ -509,7 +509,7 @@ def _scan_line_by_lot(self, picking, move_line, lot): # package, but also if we have one lot as a package and the same lot as # a unit in another line. In both cases, we want the user to scan the # package. - if packages and len({l.package_id for l in other_lot_lines}) > 1: + if packages and len({line.package_id for line in other_lot_lines}) > 1: return self._response_for_start_line( move_line, message=self.msg_store.lot_multiple_packages_scan_package() ) @@ -965,8 +965,8 @@ def _unload_write_destination_on_lines(self, lines, location): 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(line.shopfloor_unloaded for line in picking_lines): + picking._action_done() def _unload_end(self, batch, completion_info_popup=None): """Try to close the batch if all transfers are done. @@ -994,7 +994,7 @@ def _unload_end(self, batch, completion_info_popup=None): # 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.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 diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py index 44045d872cf..4c6c5e217b3 100644 --- a/shopfloor/services/delivery.py +++ b/shopfloor/services/delivery.py @@ -253,7 +253,7 @@ def _deliver_product(self, picking, product): # 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: + if packages and len({m.package_id for m in lines}) > 1: return self._response_for_deliver( new_picking, message=self.msg_store.product_multiple_packages_scan_package(), @@ -303,7 +303,7 @@ def _deliver_lot(self, picking, lot): # package, but also if we have one lot as a package and the same lot as # a unit in another line. In both cases, we want the user to scan the # package. - if packages and len({l.package_id for l in lines}) > 1: + if packages and len({m.package_id for m in lines}) > 1: return self._response_for_deliver( new_picking, message=self.msg_store.lot_multiple_packages_scan_package() ) @@ -335,7 +335,7 @@ def _action_picking_done(self, picking, force=False): if picking.state == "done": return True if force: - picking.action_done() + picking._action_done() return True all_done = False for move in picking.move_lines: @@ -346,7 +346,7 @@ def _action_picking_done(self, picking, force=False): # At least one move not satisfied, cannot mark as done automatically break if all_done: - picking.action_done() + picking._action_done() return True return False diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index e14a5fe3a27..bfe940b293b 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -724,8 +724,9 @@ def set_destination_line( # (by splitting the current one) move_line.product_uom_qty = move_line.qty_done = quantity current_move = move_line.move_id - new_move_id = current_move._split(quantity) - new_move = self.env["stock.move"].browse(new_move_id) + new_move_vals = current_move._split(quantity) + new_move = self.env["stock.move"].create(new_move_vals) + new_move._action_confirm(merge=False) new_move.move_line_ids = move_line # Ensure that the remaining qty to process is reserved as before (new_move | current_move)._recompute_state() @@ -817,6 +818,9 @@ def stock_out_package(self, location_id, package_level_id): # split the move to process only the lines related to the package. package_move.split_other_move_lines(package_move_lines) lot = package_move.move_line_ids.lot_id + # We need to set qty_done at 0 because otherwise + # the move_line will not be deleted + package_move.move_line_ids.write({"qty_done": 0}) package_move._do_unreserve() package_move._recompute_state() # Create an inventory at 0 in the move's source location @@ -872,6 +876,9 @@ def stock_out_line(self, location_id, move_line_id): move = move_line.move_id package = move_line.package_id lot = move_line.lot_id + # We need to set qty_done at 0 because otherwise + # the move_line will not be deleted + move_line.qty_done = 0 move._do_unreserve() move._recompute_state() # Create an inventory at 0 in the move's source location diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 2feb75f206e..0ec9b3814ff 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -183,11 +183,17 @@ def _response_for_select_line( data = self._data_for_move_lines(move_lines) data["confirmation_required"] = confirmation_required return self._response( - next_state="select_line", data=data, message=message, popup=popup, + next_state="select_line", + data=data, + message=message, + popup=popup, ) def _response_for_set_line_destination( - self, move_line, message=None, confirmation_required=False, + self, + move_line, + message=None, + confirmation_required=False, ): if confirmation_required and not message: message = self.msg_store.need_confirmation() @@ -200,7 +206,11 @@ def _response_for_set_line_destination( def _response_for_zero_check(self, move_line, message=None): data = self._data_for_location(move_line.location_id) data["move_line"] = self.data.move_line(move_line) - return self._response(next_state="zero_check", data=data, message=message,) + return self._response( + next_state="zero_check", + data=data, + message=message, + ) def _response_for_change_pack_lot(self, move_line, message=None): return self._response( @@ -210,7 +220,10 @@ def _response_for_change_pack_lot(self, move_line, message=None): ) def _response_for_unload_all( - self, move_lines, message=None, confirmation_required=False, + self, + move_lines, + message=None, + confirmation_required=False, ): if confirmation_required and not message: message = self.msg_store.need_confirmation() @@ -230,7 +243,10 @@ def _response_for_unload_single(self, move_line, message=None, popup=None): ) def _response_for_unload_set_destination( - self, move_line, message=None, confirmation_required=False, + self, + move_line, + message=None, + confirmation_required=False, ): if confirmation_required and not message: message = self.msg_store.need_confirmation() @@ -433,8 +449,7 @@ def _list_move_lines(self, location): return self._response_for_select_line(move_lines) def _scan_source_location(self, barcode, confirmation=False): - """Search a location and find available lines into it. - """ + """Search a location and find available lines into it.""" response = None message = None search = self._actions_for("search") @@ -455,7 +470,11 @@ def _scan_source_location(self, barcode, confirmation=False): return response, message move_lines = self._find_location_move_lines( - location, product=product, lot=lot, package=package, match_user=True, + location, + product=product, + lot=lot, + package=package, + match_user=True, ) if move_lines: response = self._response_for_set_line_destination(first(move_lines)) @@ -466,8 +485,7 @@ def _scan_source_location(self, barcode, confirmation=False): return response, message def _find_product_in_location(self, location): - """Find a prooduct in stock in given location move line in the location. - """ + """Find a prooduct in stock in given location move line in the location.""" quants = self.env["stock.quant"].search([("location_id", "=", location.id)]) product = quants.product_id lot = quants.lot_id @@ -518,8 +536,7 @@ def _scan_source_package(self, barcode, confirmation=False): return response, message def _scan_source_product(self, barcode, confirmation=False): - """Search a product and find available lines for it. - """ + """Search a product and find available lines for it.""" message = None response = None search = self._actions_for("search") @@ -535,8 +552,7 @@ def _scan_source_product(self, barcode, confirmation=False): return response, message def _scan_source_lot(self, barcode, confirmation=False): - """Search a lot and find available lines for it. - """ + """Search a lot and find available lines for it.""" message = None response = None search = self._actions_for("search") @@ -603,7 +619,8 @@ def _set_destination_location(self, move_line, quantity, confirmation, location) # expected ones but is valid (in picking type's default destination) if not self.is_dest_location_valid(move_line.move_id, location): response = self._response_for_set_line_destination( - move_line, message=self.msg_store.dest_location_not_allowed(), + move_line, + message=self.msg_store.dest_location_not_allowed(), ) return (location_changed, response) @@ -622,7 +639,8 @@ def _set_destination_location(self, move_line, quantity, confirmation, location) # If no destination package if not move_line.result_package_id: response = self._response_for_set_line_destination( - move_line, message=self.msg_store.dest_package_required(), + move_line, + message=self.msg_store.dest_package_required(), ) return (location_changed, response) # destination location set to the scanned one @@ -676,12 +694,14 @@ def _set_destination_package(self, move_line, quantity, package): # * not used as destination for another move line if not self._is_package_empty(package): response = self._response_for_set_line_destination( - move_line, message=self.msg_store.package_not_empty(package), + move_line, + message=self.msg_store.package_not_empty(package), ) return (package_changed, response) if self._is_package_already_used(package): response = self._response_for_set_line_destination( - move_line, message=self.msg_store.package_already_used(package), + move_line, + message=self.msg_store.package_already_used(package), ) return (package_changed, response) # the quantity done is set to the passed quantity @@ -722,7 +742,11 @@ def _set_move_line_as_done(self, move_line, quantity, package, user=None): # flake8: noqa: C901 def set_destination( - self, move_line_id, barcode, quantity, confirmation=False, + self, + move_line_id, + barcode, + quantity, + confirmation=False, ): """Set a destination location (and done) or a destination package (in buffer) @@ -795,7 +819,10 @@ def set_destination( move_line, message=message ) pkg_moved, response = self._set_destination_location( - move_line, quantity, confirmation, location, + move_line, + quantity, + confirmation, + location, ) if response: if extra_message: @@ -1046,7 +1073,8 @@ def change_pack_lot(self, move_line_id, barcode): return response 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 prepare_unload(self): @@ -1190,7 +1218,8 @@ def unload_split(self): # no remaining move lines in buffer move_lines = self._find_location_move_lines() return self._response_for_select_line( - move_lines, message=self.msg_store.buffer_complete(), + move_lines, + message=self.msg_store.buffer_complete(), ) def _unload_response(self, unload_single_message=None): @@ -1199,14 +1228,16 @@ def _unload_response(self, unload_single_message=None): move_lines = self._find_buffer_move_lines() if move_lines: return self._response_for_unload_single( - first(move_lines), message=unload_single_message, + 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() if move_lines: return self._response_for_select_line( - move_lines, message=self.msg_store.buffer_complete(), + move_lines, + message=self.msg_store.buffer_complete(), ) # no more move lines to process from the current picking type # => picking type complete! @@ -1273,7 +1304,8 @@ def unload_set_destination(self, package_id, barcode, confirmation=False): if not package.exists() or not buffer_lines: move_lines = self._find_location_move_lines() return self._response_for_select_line( - move_lines, message=self.msg_store.record_not_found(), + move_lines, + message=self.msg_store.record_not_found(), ) search = self._actions_for("search") location = search.location_from_scan(barcode) @@ -1317,7 +1349,8 @@ def unload_set_destination(self, package_id, barcode, confirmation=False): move_lines = self._find_location_move_lines() if move_lines: return self._response_for_select_line( - move_lines, message=self.msg_store.buffer_complete(), + move_lines, + message=self.msg_store.buffer_complete(), ) return self._response_for_start( message=self.msg_store.picking_type_complete(self.picking_type) @@ -1326,7 +1359,8 @@ def unload_set_destination(self, package_id, barcode, confirmation=False): # 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( - first(buffer_lines), message=self.msg_store.no_location_found(), + first(buffer_lines), + message=self.msg_store.no_location_found(), ) diff --git a/shopfloor/tests/models.py b/shopfloor/tests/models.py index ca9aa69c3c2..cfbced98407 100644 --- a/shopfloor/tests/models.py +++ b/shopfloor/tests/models.py @@ -13,7 +13,9 @@ class DeliveryCarrierTest(models.Model): _inherit = "delivery.carrier" - delivery_type = fields.Selection(selection_add=[("test", "TEST")]) + delivery_type = fields.Selection( + selection_add=[("test", "TEST")], ondelete={"test": "set default"} + ) test_default_packaging_id = fields.Many2one( "product.packaging", string="Default Package Type" ) @@ -22,4 +24,6 @@ class DeliveryCarrierTest(models.Model): class ProductPackagingTest(models.Model): _inherit = "product.packaging" - package_carrier_type = fields.Selection(selection_add=[("test", "TEST")]) + package_carrier_type = fields.Selection( + selection_add=[("test", "TEST")], ondelete={"test": "set default"} + ) diff --git a/shopfloor/tests/test_actions_change_package_lot.py b/shopfloor/tests/test_actions_change_package_lot.py index 03678c0b565..5f2e6a6e18d 100644 --- a/shopfloor/tests/test_actions_change_package_lot.py +++ b/shopfloor/tests/test_actions_change_package_lot.py @@ -731,7 +731,7 @@ def test_change_pack_different_location_reserved_package_qty_done(self): 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 + new_package.display_name ), ) self.change_package_lot.change_package( diff --git a/shopfloor/tests/test_actions_data_base.py b/shopfloor/tests/test_actions_data_base.py index f1312ec30ae..0a80bde44bd 100644 --- a/shopfloor/tests/test_actions_data_base.py +++ b/shopfloor/tests/test_actions_data_base.py @@ -173,7 +173,7 @@ def _expected_package(self, record, **kw): data = { "id": record.id, "name": record.name, - "weight": record.pack_weight or record.estimated_pack_weight, + "weight": record.pack_weight or record.estimated_pack_weight_kg, "storage_type": None, } data.update(kw) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 5154b2467da..a694ee5970f 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -43,7 +43,7 @@ def test_data_lot(self): "company_id": self.env.company.id, "ref": "#FOO", "removal_date": "2020-05-20", - "life_date": "2020-05-31", + "expiration_date": "2020-05-31", } ) data = self.data_detail.lot_detail(lot) @@ -98,11 +98,11 @@ def test_data_picking(self): { "origin": "created by test", "note": "read me", - "priority": "3", + "priority": "1", "carrier_id": carrier.id, } ) - picking.move_lines.write({"date_expected": "2020-05-13"}) + picking.move_lines.write({"date": "2020-05-13"}) data = self.data_detail.picking_detail(picking) self.assert_schema(self.schema_detail.picking_detail(), data) expected = { @@ -114,7 +114,7 @@ def test_data_picking(self): "weight": 110.0, "partner": {"id": self.customer.id, "name": self.customer.name}, "carrier": {"id": picking.carrier_id.id, "name": picking.carrier_id.name}, - "priority": "Very Urgent", + "priority": "Urgent", "operation_type": { "id": picking.picking_type_id.id, "name": picking.picking_type_id.name, diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py index b3bcf36aa60..727c8386892 100644 --- a/shopfloor/tests/test_checkout_change_packaging.py +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -22,7 +22,7 @@ def setUpClassBaseData(cls): "barcode": "PPP", "height": 100, "width": 100, - "lngth": 100, + "packaging_length": 100, } ) ) @@ -41,7 +41,7 @@ def setUpClassBaseData(cls): "barcode": "BBB", "height": 20, "width": 20, - "lngth": 20, + "packaging_length": 20, } ) ) @@ -60,7 +60,7 @@ def setUpClassBaseData(cls): "barcode": "III", "height": 10, "width": 10, - "lngth": 10, + "packaging_length": 10, } ) ) diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py index 6cc8c5708c1..e85b6444583 100644 --- a/shopfloor/tests/test_checkout_scan.py +++ b/shopfloor/tests/test_checkout_scan.py @@ -147,9 +147,10 @@ def test_scan_document_recover(self): 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}, + "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.assertTrue(all(m.qty_done for m 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 diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py index 39c87e92aef..f049bf4400d 100644 --- a/shopfloor/tests/test_checkout_scan_package_action.py +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -312,7 +312,7 @@ def test_scan_package_action_scan_packaging_ok(self): "barcode": "PPP", "height": 12, "width": 13, - "lngth": 14, + "packaging_length": 14, } ) ) @@ -335,7 +335,7 @@ def test_scan_package_action_scan_packaging_ok(self): [ { "packaging_id": packaging.id, - "lngth": packaging.lngth, + "pack_length": packaging.packaging_length, "width": packaging.width, "height": packaging.height, } @@ -382,7 +382,7 @@ def test_scan_package_action_scan_packaging_bad_carrier(self): "barcode": "XXX", "height": 12, "width": 13, - "lngth": 14, + "packaging_length": 14, } ) ) @@ -390,10 +390,14 @@ def test_scan_package_action_scan_packaging_bad_carrier(self): # depend on specific implementations that we don't have as dependency. # What is important here is to simulate their value when mismatching. mock1 = mock.patch.object( - type(packaging), "package_carrier_type", new_callable=mock.PropertyMock, + type(packaging), + "package_carrier_type", + new_callable=mock.PropertyMock, ) mock2 = mock.patch.object( - type(picking.carrier_id), "delivery_type", new_callable=mock.PropertyMock, + type(picking.carrier_id), + "delivery_type", + new_callable=mock.PropertyMock, ) with mock1 as mocked_package_carrier_type, mock2 as mocked_delivery_type: # Not matching at all -> bad diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py index 60968810b17..4a0d2b48add 100644 --- a/shopfloor/tests/test_checkout_select.py +++ b/shopfloor/tests/test_checkout_select.py @@ -69,6 +69,4 @@ 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) - ) + self._test_error(picking, "You cannot move this using this menu.") diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py index 4b3abd6dc07..021794c7804 100644 --- a/shopfloor/tests/test_checkout_select_package_base.py +++ b/shopfloor/tests/test_checkout_select_package_base.py @@ -27,12 +27,17 @@ def _assert_selected_response( ) def _assert_selected_qties( - self, response, selected_lines, lines_quantities, message=None, packing_info="", + self, + response, + selected_lines, + lines_quantities, + message=None, + packing_info="", ): picking = selected_lines.mapped("picking_id") deselected_lines = picking.move_line_ids - selected_lines self.assertEqual( - sorted(selected_lines.ids), sorted([l.id for l in lines_quantities]) + sorted(selected_lines.ids), sorted([line.id for line in lines_quantities]) ) for line, quantity in lines_quantities.items(): self.assertEqual(line.qty_done, quantity) diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py index b49b1aed5eb..d31b88ac055 100644 --- a/shopfloor/tests/test_checkout_set_qty.py +++ b/shopfloor/tests/test_checkout_set_qty.py @@ -28,9 +28,9 @@ def setUp(self): 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) + all(line.qty_done == line.product_uom_qty for line in self.selected_lines) ) - self.assertTrue(all(l.qty_done == 0 for l in self.deselected_lines)) + self.assertTrue(all(line.qty_done == 0 for line in self.deselected_lines)) class CheckoutResetLineQtyCase(CheckoutSetQtyCommonCase): diff --git a/shopfloor/tests/test_cluster_picking_batch.py b/shopfloor/tests/test_cluster_picking_batch.py index fcb3f0642a7..e208b857a09 100644 --- a/shopfloor/tests/test_cluster_picking_batch.py +++ b/shopfloor/tests/test_cluster_picking_batch.py @@ -71,10 +71,10 @@ def test_search(self): 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() + self.batch4.action_confirm() # unless it's assigned to our user self.batch3.user_id = self.env.user - self.batch3.confirm_picking() + self.batch3.action_confirm() # Simulate the client asking the list of picking batch res = self.service._batch_picking_search() diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py index 02f35414afd..200cf80a5e9 100644 --- a/shopfloor/tests/test_cluster_picking_select.py +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -25,7 +25,9 @@ class ClusterPickingSelectionCase(ClusterPickingCommonCase): 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() + old_batchs = cls.env["stock.picking.batch"].search([]) + old_batchs.write({"state": "draft"}) + old_batchs.unlink() cls.batch1 = cls._create_picking_batch( [[cls.BatchProduct(product=cls.product_a, quantity=3)]] ) @@ -51,7 +53,7 @@ def test_find_batch_in_progress_current_user(self): self.batch1 | self.batch2 | self.batch3 ) self.batch3.user_id = self.env.uid - self.batch3.confirm_picking() # set to in progress + self.batch3.action_confirm() # set to in progress response = self.service.dispatch("find_batch") # we expect to find batch 3 as it's assigned to the current @@ -206,6 +208,7 @@ def test_select_draft_unassigned(self): def test_select_not_exists(self): """Select a draft that does not exist""" batch_id = self.batch1.id + self.batch1.state = "draft" self.batch1.unlink() # Simulate the client selecting the batch in a list response = self.service.dispatch( @@ -255,6 +258,7 @@ def test_unassign_batch(self): def test_unassign_batch_not_exists(self): """User cancels after selecting a batch deleted meanwhile""" batch_id = self.batch1.id + self.batch1.state = "draft" self.batch1.unlink() # Simulate the client selecting the batch in a list response = self.service.dispatch( @@ -297,8 +301,8 @@ def test_lines_order(self): # 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}) + move1.write({"date": today}) + (batch.picking_ids.move_lines - move1).write({"date": future}) move_lines = self.service._lines_for_picking_batch(batch) order_mapping = {line: i for i, line in enumerate(move_lines)} @@ -306,8 +310,8 @@ def test_lines_order(self): # 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}) + move1.write({"date": future}) + (batch.picking_ids.move_lines - move1).write({"date": today}) move_lines = self.service._lines_for_picking_batch(batch) order_mapping = {line: i for i, line in enumerate(move_lines)} @@ -343,6 +347,7 @@ 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.batch.id + self.batch.state = "draft" self.batch.unlink() response = self.service.dispatch( "confirm_start", params={"picking_batch_id": batch_id} @@ -366,7 +371,7 @@ def test_confirm_start_all_is_done(self): self.batch.mapped("picking_ids.move_line_ids"), self.env["stock.quant.package"].create({}), ) - self.batch.done() + self.batch.action_done() response = self.service.dispatch( "confirm_start", params={"picking_batch_id": self.batch.id} ) diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py index 68980b28518..da84811c853 100644 --- a/shopfloor/tests/test_cluster_picking_unload.py +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -600,7 +600,7 @@ def test_unload_scan_destination_ok(self): 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() + 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 @@ -662,7 +662,7 @@ def test_unload_scan_destination_one_line_of_picking_only(self): 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() + 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({}) diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py index ae2cd598f67..d7d5177853f 100644 --- a/shopfloor/tests/test_delivery_scan_deliver.py +++ b/shopfloor/tests/test_delivery_scan_deliver.py @@ -282,7 +282,7 @@ def test_scan_deliver_error_picking_already_done(self): 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() + picking._action_done() response = self.service.dispatch( "scan_deliver", params={"barcode": picking.name} ) diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py index d5e2377f6dc..7ba6825bfda 100644 --- a/shopfloor/tests/test_location_content_transfer_mix.py +++ b/shopfloor/tests/test_location_content_transfer_mix.py @@ -120,7 +120,10 @@ 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( - move_line.id, dest_location.barcode, qty, confirmation=True, + move_line.id, + dest_location.barcode, + qty, + confirmation=True, ) assert response["message"]["message_type"] == "success" self.assertEqual(move_line.state, "done") @@ -477,7 +480,8 @@ def test_with_zone_picking3(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, 6) 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 a74ab138d3b..d347b3f474a 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 @@ -760,7 +760,7 @@ def test_set_destination_line_partial_qty_with_move_orig_ids(self): picking_a = self.picking_a picking_b = self.picking_b picking_a.move_line_ids.qty_done = 10 - picking_a.action_done() + picking_a._action_done() self.assertEqual(picking_a.state, "done") self.assertEqual(picking_b.state, "assigned") self._simulate_pickings_selected(picking_b) @@ -816,7 +816,7 @@ def test_set_destination_package_partial_qty_with_move_orig_ids(self): line1.qty_done = 6 line1.result_package_id = package1 line2.result_package_id = package2 - picking_a.action_done() + picking_a._action_done() self.assertEqual(picking_a.state, "done") self.assertEqual(picking_b.state, "assigned") # we have 1 move line per package diff --git a/shopfloor/tests/test_menu_base.py b/shopfloor/tests/test_menu_base.py index 59cbb05c673..8d4ce672f61 100644 --- a/shopfloor/tests/test_menu_base.py +++ b/shopfloor/tests/test_menu_base.py @@ -215,7 +215,7 @@ def setUpClassBaseData(cls, *args, **kwargs): cls.picking1 = picking1 = cls._create_picking( picking_type=cls.menu1_picking_type, lines=[(cls.product_a, 10)] ) - picking1.priority = "2" + picking1.priority = "0" cls._fill_stock_for_moves( picking1.move_lines, in_package=True, location=cls.zone_sublocation1 ) @@ -224,7 +224,7 @@ def setUpClassBaseData(cls, *args, **kwargs): picking_type=cls.menu1_picking_type, lines=[(cls.product_b, 10), (cls.product_c, 10)], ) - picking2.priority = "3" + picking2.priority = "1" cls._fill_stock_for_moves( picking2.move_lines, in_lot=True, location=cls.zone_sublocation2 ) @@ -232,7 +232,7 @@ def setUpClassBaseData(cls, *args, **kwargs): cls.picking3 = picking3 = cls._create_picking( picking_type=cls.menu1_picking_type, lines=[(cls.product_d, 10)] ) - picking3.priority = "2" + picking3.priority = "0" cls._fill_stock_for_moves(picking3.move_lines, location=cls.zone_sublocation1) cls.picking4 = picking4 = cls._create_picking( diff --git a/shopfloor/tests/test_menu_counters.py b/shopfloor/tests/test_menu_counters.py index 61de5416b20..e63615fdcea 100644 --- a/shopfloor/tests/test_menu_counters.py +++ b/shopfloor/tests/test_menu_counters.py @@ -9,8 +9,8 @@ def test_menu_search(self): self.menu1.id: { "lines_count": 2, "picking_count": 2, - "priority_lines_count": 2, - "priority_picking_count": 2, + "priority_lines_count": 0, + "priority_picking_count": 0, }, self.menu2.id: { "lines_count": 6, diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py index 969c631d447..7649a0c9ef7 100644 --- a/shopfloor/tests/test_single_pack_transfer.py +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -817,6 +817,7 @@ def test_cancel_already_canceled(self): move_lines_a = package_level_a.move_line_ids picking = move_a.picking_id # someone cancel the work started by our operator + move_lines_a.write({"qty_done": 0}) move_a._action_cancel() # now, call the service to cancel the first package @@ -840,6 +841,8 @@ def test_cancel_already_canceled(self): # keep references for later checks move_b = package_level_b.move_line_ids.move_id # someone cancel the work started by our operator + move_lines_b = package_level_b.move_line_ids + move_lines_b.write({"qty_done": 0}) move_b._action_cancel() # then cancel the second package response = self.service.dispatch( diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py index 51ac41218ac..b72d896e4f6 100644 --- a/shopfloor/tests/test_zone_picking_base.py +++ b/shopfloor/tests/test_zone_picking_base.py @@ -263,7 +263,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): @@ -276,7 +279,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( @@ -313,7 +319,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( @@ -379,7 +389,13 @@ def assert_response_set_line_destination( ) def _assert_response_zero_check( - 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, @@ -394,7 +410,12 @@ def _assert_response_zero_check( ) def assert_response_zero_check( - self, response, zone_location, picking_type, move_line, message=None, + self, + response, + zone_location, + picking_type, + move_line, + message=None, ): self._assert_response_zero_check( "zero_check", @@ -406,7 +427,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, @@ -420,7 +447,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_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py index 61323fef4e9..0ddd42ad7a4 100644 --- a/shopfloor/tests/test_zone_picking_select_line.py +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -28,10 +28,10 @@ def test_list_move_lines_order(self): ) # change date to lines in the same location move1 = self.picking2.move_lines[0] - move1.write({"date_expected": today}) + move1.write({"date": today}) move1_line = move1.move_line_ids[0] move2 = self.picking2.move_lines[1] - move2.write({"date_expected": future}) + move2.write({"date": future}) move2_line = move2.move_line_ids[0] self.service.work.current_lines_order = "location" @@ -40,28 +40,31 @@ def test_list_move_lines_order(self): self.assertTrue(order_mapping[move1_line] < order_mapping[move2_line]) # swap dates - move2.write({"date_expected": today}) - move1.write({"date_expected": future}) + move2.write({"date": today}) + move1.write({"date": future}) move_lines = self.service._find_location_move_lines() 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"}) + self.picking2.write({"priority": "0"}) + (self.pickings - self.picking2).write({"priority": "1"}) self.service.work.current_lines_order = "priority" move_lines = self.service._find_location_move_lines() 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) + self.assertTrue(move1_line in move_lines[-2:]) + self.assertTrue(move2_line in move_lines[-2:]) + # but move1_line comes after the other + self.assertTrue(order_mapping[move1_line] > order_mapping[move2_line]) + # swap dates again - move2.write({"date_expected": future}) - move1.write({"date_expected": today}) + move2.write({"date": future}) + move1.write({"date": today}) # and increase priority - self.picking2.move_lines.write({"priority": "3"}) + self.picking2.write({"priority": "1"}) + (self.pickings - self.picking2).write({"priority": "0"}) move_lines = self.service._find_location_move_lines() order_mapping = {line: i for i, line in enumerate(move_lines)} self.assertEqual(order_mapping[move1_line], 0) @@ -78,23 +81,31 @@ def test_list_move_lines_order_by_location(self): self.assertEqual(res, [x.location_id.name for x in move_lines]) self.maxDiff = None self.assert_response_select_line( - response, self.zone_location, self.picking1.picking_type_id, move_lines, + response, + self.zone_location, + self.picking1.picking_type_id, + move_lines, ) def test_list_move_lines_order_by_priority(self): response = self.service.dispatch("list_move_lines", params={}) move_lines = self.service._find_location_move_lines() self.assert_response_select_line( - response, self.zone_location, self.picking_type, move_lines, + response, + self.zone_location, + self.picking_type, + move_lines, ) def test_scan_source_barcode_location_not_allowed(self): """Scan source: scanned location not allowed.""" response = self.service.dispatch( - "scan_source", params={"barcode": self.customer_location.barcode}, + "scan_source", + 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_source_barcode_location_one_move_line(self): @@ -102,7 +113,8 @@ def test_scan_source_barcode_location_one_move_line(self): one move line, next step 'set_line_destination' expected. """ response = self.service.dispatch( - "scan_source", params={"barcode": self.zone_sublocation1.barcode}, + "scan_source", + params={"barcode": self.zone_sublocation1.barcode}, ) move_line = self.picking1.move_line_ids self.assert_response_set_line_destination( @@ -126,7 +138,8 @@ def test_scan_source_barcode_location_two_move_lines_same_product(self): ) new_picking.action_assign() response = self.service.dispatch( - "scan_source", params={"barcode": self.zone_sublocation1.barcode}, + "scan_source", + params={"barcode": self.zone_sublocation1.barcode}, ) move_line = self.picking1.move_line_ids self.assert_response_set_line_destination( @@ -139,7 +152,8 @@ def test_scan_source_barcode_location_two_move_lines_same_product(self): move_line.qty_done = move_line.product_uom_qty # get the next one response = self.service.dispatch( - "scan_source", params={"barcode": self.zone_sublocation1.barcode}, + "scan_source", + params={"barcode": self.zone_sublocation1.barcode}, ) move_line = new_picking.move_line_ids self.assert_response_set_line_destination( @@ -155,7 +169,8 @@ def test_scan_source_barcode_location_several_move_lines(self): move lines. """ response = self.service.dispatch( - "scan_source", params={"barcode": self.zone_sublocation2.barcode}, + "scan_source", + params={"barcode": self.zone_sublocation2.barcode}, ) move_lines = self.picking2.move_line_ids self.assert_response_select_line( @@ -174,9 +189,12 @@ def test_scan_source_barcode_package(self): """ package = self.picking1.package_level_ids[0].package_id response = self.service.dispatch( - "scan_source", params={"barcode": package.name}, + "scan_source", + params={"barcode": package.name}, + ) + move_lines = self.service._find_location_move_lines( + package=package, ) - move_lines = self.service._find_location_move_lines(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( @@ -191,7 +209,10 @@ def test_scan_source_barcode_package_not_found(self): next step 'select_line' expected. """ pack_code = self.free_package.name - response = self.service.dispatch("scan_source", params={"barcode": pack_code},) + response = self.service.dispatch( + "scan_source", + params={"barcode": pack_code}, + ) move_lines = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( @@ -207,7 +228,8 @@ def test_scan_source_barcode_package_not_exist(self): next step 'select_line' expected. """ response = self.service.dispatch( - "scan_source", params={"barcode": "P-Unknown"}, + "scan_source", + params={"barcode": "P-Unknown"}, ) move_lines = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) @@ -236,9 +258,12 @@ def test_scan_source_barcode_package_can_replace_in_line(self): package1 = self.picking1.package_level_ids[0].package_id # 1st scan response = self.service.dispatch( - "scan_source", params={"barcode": package1b.name}, + "scan_source", + params={"barcode": package1b.name}, + ) + move_lines = self.service._find_location_move_lines( + package=package1, ) - move_lines = self.service._find_location_move_lines(package=package1,) move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) self.assert_response_select_line( response, @@ -251,7 +276,8 @@ def test_scan_source_barcode_package_can_replace_in_line(self): self.assertEqual(self.picking1.package_level_ids[0].package_id, package1) # 2nd scan response = self.service.dispatch( - "scan_source", params={"barcode": package1b.name, "confirmation": True}, + "scan_source", + params={"barcode": package1b.name, "confirmation": True}, ) self.assert_response_set_line_destination( response, @@ -270,9 +296,12 @@ def test_scan_source_barcode_product(self): next step 'set_line_destination' expected on it. """ response = self.service.dispatch( - "scan_source", params={"barcode": self.product_a.barcode}, + "scan_source", + params={"barcode": self.product_a.barcode}, + ) + move_line = self.service._find_location_move_lines( + product=self.product_a, ) - move_line = self.service._find_location_move_lines(product=self.product_a,) self.assert_response_set_line_destination( response, zone_location=self.zone_location, @@ -285,7 +314,8 @@ def test_scan_source_barcode_product_not_found(self): next step 'select_line' expected. """ response = self.service.dispatch( - "scan_source", params={"barcode": self.free_product.barcode}, + "scan_source", + params={"barcode": self.free_product.barcode}, ) move_lines = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) @@ -302,7 +332,10 @@ def test_scan_source_barcode_lot(self): next step 'set_line_destination' expected on it. """ lot = self.picking2.move_line_ids.lot_id[0] - response = self.service.dispatch("scan_source", params={"barcode": lot.name},) + response = self.service.dispatch( + "scan_source", + params={"barcode": lot.name}, + ) move_lines = self.service._find_location_move_lines(lot=lot) move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) move_line = move_lines[0] @@ -318,7 +351,8 @@ def test_scan_source_barcode_lot_not_found(self): next step 'select_line' expected. """ response = self.service.dispatch( - "scan_source", params={"barcode": self.free_lot.name}, + "scan_source", + params={"barcode": self.free_lot.name}, ) move_lines = self.service._find_location_move_lines() move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) @@ -356,12 +390,16 @@ def test_scan_source_multi_users(self): """ # The first user starts to process the only line available # - scan source - response = self.service.scan_source(self.zone_sublocation1.barcode,) + response = self.service.scan_source( + 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( - move_line.id, self.free_package.name, move_line.product_uom_qty, + 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 @@ -374,7 +412,9 @@ def test_scan_source_multi_users(self): current_picking_type=self.picking_type, ) as work: service = work.component(usage="zone_picking") - response = service.scan_source(self.zone_sublocation1.barcode,) + response = service.scan_source( + self.zone_sublocation1.barcode, + ) self.assertEqual(response["next_state"], "select_line") self.assertEqual( response["message"], @@ -383,11 +423,17 @@ def test_scan_source_multi_users(self): def test_prepare_unload_buffer_empty(self): # unload goods - response = self.service.dispatch("prepare_unload", params={},) + response = self.service.dispatch( + "prepare_unload", + params={}, + ) # check response move_lines = self.service._find_location_move_lines() self.assert_response_select_line( - response, self.zone_location, self.picking_type, move_lines, + response, + self.zone_location, + self.picking_type, + move_lines, ) def test_prepare_unload_buffer_one_line(self): @@ -402,10 +448,16 @@ def test_prepare_unload_buffer_one_line(self): }, ) # unload goods - response = self.service.dispatch("prepare_unload", params={},) + response = self.service.dispatch( + "prepare_unload", + params={}, + ) # check response self.assert_response_unload_set_destination( - response, self.zone_location, self.picking_type, move_line, + response, + self.zone_location, + self.picking_type, + move_line, ) def test_prepare_unload_buffer_multi_line_same_destination(self): @@ -429,7 +481,10 @@ def test_prepare_unload_buffer_multi_line_same_destination(self): }, ) # unload goods - response = self.service.dispatch("prepare_unload", params={},) + response = self.service.dispatch( + "prepare_unload", + params={}, + ) # check response self.assert_response_unload_all( response, @@ -440,12 +495,16 @@ def test_prepare_unload_buffer_multi_line_same_destination(self): def test_list_move_lines_empty_location(self): response = self.service.dispatch( - "list_move_lines", params={"order": "location"}, + "list_move_lines", + params={"order": "location"}, ) # TODO: order by location? move_lines = self.service._find_location_move_lines() self.assert_response_select_line( - response, self.zone_location, self.picking_type, move_lines, + response, + self.zone_location, + self.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 e2639db1f3e..ef50bea0f1c 100644 --- a/shopfloor/tests/test_zone_picking_select_picking_type.py +++ b/shopfloor/tests/test_zone_picking_select_picking_type.py @@ -13,7 +13,10 @@ class ZonePickingSelectPickingTypeCase(ZonePickingCommonCase): 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={},) + response = self.service.dispatch( + "list_move_lines", + params={}, + ) move_lines = self.service._find_location_move_lines(zone_location, picking_type) self.assert_response_select_line( response, diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py index 9fa9bca9241..9b6f8e97596 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(), ) def test_set_destination_location_confirm(self): @@ -492,5 +493,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, + response, + zone_location, + picking_type, + move_line, ) diff --git a/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py b/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py index 377f4ad4552..84e106e238d 100644 --- a/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py +++ b/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py @@ -47,8 +47,7 @@ def setUp(self): self.menu.sudo().pick_pack_same_time = True def test_set_destination_location_no_carrier(self): - """Scan location but carrier not set on picking - """ + """Scan location but carrier not set on picking""" zone_location = self.zone_location picking_type = self.picking1.picking_type_id move_line = self.picking1.move_line_ids @@ -101,12 +100,15 @@ def test_set_destination_location_ok_carrier(self): message = self.msg_store.confirm_pack_moved() message["body"] += "\n" + self.msg_store.goods_packed_in(delivery_pkg)["body"] self.assert_response_select_line( - response, zone_location, picking_type, move_lines, message=message, + response, + zone_location, + picking_type, + move_lines, + message=message, ) def test_set_destination_package_full_qty_no_carrier(self): - """Scan destination package, no carrier on picking. - """ + """Scan destination package, no carrier on picking.""" zone_location = self.zone_location picking_type = self.picking1.picking_type_id moves_before = self.picking1.move_lines @@ -133,8 +135,7 @@ def test_set_destination_package_full_qty_no_carrier(self): ) def test_set_destination_package_full_qty_ok_carrier_bad_package(self): - """Scan destination package, carrier on picking, package invalid. - """ + """Scan destination package, carrier on picking, package invalid.""" zone_location = self.zone_location picking_type = self.picking1.picking_type_id moves_before = self.picking1.move_lines @@ -162,8 +163,7 @@ def test_set_destination_package_full_qty_ok_carrier_bad_package(self): ) def test_set_destination_package_full_qty_ok_carrier_ok_package(self): - """Scan destination package, carrier on picking, package valid. - """ + """Scan destination package, carrier on picking, package valid.""" zone_location = self.zone_location picking_type = self.picking1.picking_type_id moves_before = self.picking1.move_lines diff --git a/shopfloor/tests/test_zone_picking_stock_issue.py b/shopfloor/tests/test_zone_picking_stock_issue.py index bd4b4f110a3..a7d282e55bd 100644 --- a/shopfloor/tests/test_zone_picking_stock_issue.py +++ b/shopfloor/tests/test_zone_picking_stock_issue.py @@ -16,10 +16,12 @@ def setUp(self): def test_stock_issue_wrong_parameters(self): response = self.service.dispatch( - "stock_issue", params={"move_line_id": 1234567890}, + "stock_issue", + params={"move_line_id": 1234567890}, ) 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): @@ -28,13 +30,17 @@ def test_stock_issue_no_more_reservation(self): move_line = self.picking1.move_line_ids[0] move = move_line.move_id response = self.service.dispatch( - "stock_issue", params={"move_line_id": move_line.id}, + "stock_issue", + params={"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() self.assert_response_select_line( - response, zone_location, picking_type, move_lines, + response, + zone_location, + picking_type, + move_lines, ) def test_stock_issue1(self): @@ -45,13 +51,17 @@ def test_stock_issue1(self): location = move_line.location_id move = move_line.move_id response = self.service.dispatch( - "stock_issue", params={"move_line_id": move_line.id}, + "stock_issue", + params={"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() 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( @@ -82,13 +92,17 @@ def test_stock_issue2(self): # Increase the quantity in the current location self._update_qty_in_location(location, move.product_id, 100) response = self.service.dispatch( - "stock_issue", params={"move_line_id": move_line.id}, + "stock_issue", + params={"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, + response, + zone_location, + picking_type, + move.move_line_ids, ) # Check the inventory inventory = self.env["stock.inventory"].search( @@ -121,13 +135,17 @@ def test_stock_issue3(self): # 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={"move_line_id": move_line.id}, + "stock_issue", + params={"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, + 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 922768a6f35..760ba9abc4e 100644 --- a/shopfloor/tests/test_zone_picking_unload_all.py +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -27,14 +27,19 @@ def test_set_destination_all_different_destination(self): move_line2.location_dest_id = self.zone_sublocation3 # set the destination package on lines self.service._set_destination_package( - move_line1, move_line1.product_uom_qty, self.free_package, + move_line1, + move_line1.product_uom_qty, + self.free_package, ) self.service._set_destination_package( - move_line2, move_line2.product_uom_qty, another_package, + 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={"barcode": self.packing_location.barcode}, + "set_destination_all", + params={"barcode": self.packing_location.barcode}, ) # check response buffer_lines = self.service._find_buffer_move_lines() @@ -78,10 +83,14 @@ def test_set_destination_all_confirm_destination(self): ) # set the destination package on lines self.service._set_destination_package( - move_line1, move_line1.product_uom_qty, self.free_package, + move_line1, + move_line1.product_uom_qty, + self.free_package, ) self.service._set_destination_package( - move_line2, move_line2.product_uom_qty, another_package, + move_line2, + move_line2.product_uom_qty, + another_package, ) # set an allowed destination location (inside the picking type default # destination location) for all lines in the buffer with a non-expected @@ -89,7 +98,8 @@ def test_set_destination_all_confirm_destination(self): # lines destination (move_line1 | move_line2).location_dest_id = packing_sublocation1 response = self.service.dispatch( - "set_destination_all", params={"barcode": packing_sublocation2.barcode}, + "set_destination_all", + params={"barcode": packing_sublocation2.barcode}, ) # check response: this destination needs the user confirmation buffer_lines = self.service._find_buffer_move_lines() @@ -99,7 +109,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, ) @@ -108,7 +119,8 @@ def test_set_destination_all_confirm_destination(self): # meaning a destination which is a child of the current buffer lines # destination response = self.service.dispatch( - "set_destination_all", params={"barcode": packing_sublocation1.barcode}, + "set_destination_all", + params={"barcode": packing_sublocation1.barcode}, ) # check response: OK move_lines = self.service._find_location_move_lines() @@ -130,14 +142,19 @@ def test_set_destination_all_ok(self): ) # set the destination package on lines self.service._set_destination_package( - move_line1, move_line1.product_uom_qty, self.free_package, + move_line1, + move_line1.product_uom_qty, + self.free_package, ) self.service._set_destination_package( - move_line2, move_line2.product_uom_qty, another_package, + 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={"barcode": self.packing_location.barcode}, + "set_destination_all", + params={"barcode": self.packing_location.barcode}, ) # check data self.assertEqual(self.picking5.state, "done") @@ -172,14 +189,19 @@ def test_set_destination_all_partial_qty_done_ok(self): ) # set the destination package on lines self.service._set_destination_package( - move_line_g, move_line_g.product_uom_qty, self.free_package, + move_line_g, + move_line_g.product_uom_qty, + self.free_package, ) self.service._set_destination_package( - move_line_h, move_line_h.product_uom_qty, another_package, # partial qty + move_line_h, + move_line_h.product_uom_qty, + another_package, # partial qty ) # set destination location for all lines in the buffer response = self.service.dispatch( - "set_destination_all", params={"barcode": self.packing_location.barcode}, + "set_destination_all", + params={"barcode": self.packing_location.barcode}, ) # check data # picking validated @@ -215,10 +237,13 @@ def test_set_destination_all_location_not_allowed(self): move_line = self.picking1.move_line_ids # set the destination package on lines self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, ) response = self.service.dispatch( - "set_destination_all", params={"barcode": self.customer_location.barcode}, + "set_destination_all", + params={"barcode": self.customer_location.barcode}, ) # check response buffer_lines = self.service._find_buffer_move_lines() @@ -236,10 +261,13 @@ def test_set_destination_all_location_not_found(self): move_line = self.picking1.move_line_ids # set the destination package on lines self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, ) response = self.service.dispatch( - "set_destination_all", params={"barcode": "UNKNOWN"}, + "set_destination_all", + params={"barcode": "UNKNOWN"}, ) # check response buffer_lines = self.service._find_buffer_move_lines() @@ -254,7 +282,10 @@ def test_set_destination_all_location_not_found(self): 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={},) + response = self.service.dispatch( + "unload_split", + params={}, + ) # check response move_lines = self.service._find_location_move_lines() self.assert_response_select_line( @@ -271,13 +302,21 @@ def test_unload_split_buffer_one_line(self): move_line = self.picking1.move_line_ids # put one line in the buffer self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_split", + params={}, ) - response = self.service.dispatch("unload_split", params={},) # check response buffer_lines = self.service._find_buffer_move_lines() 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): @@ -292,9 +331,14 @@ def test_unload_split_buffer_multi_lines(self): self.picking5.move_line_ids, self.free_package | self.another_package ): self.service._set_destination_package( - move_line, move_line.product_uom_qty, package_dest, + move_line, + move_line.product_uom_qty, + package_dest, ) - response = self.service.dispatch("unload_split", params={},) + response = self.service.dispatch( + "unload_split", + params={}, + ) # check response buffer_lines = self.service._find_buffer_move_lines() completion_info = self.service._actions_for("completion.info") diff --git a/shopfloor/tests/test_zone_picking_unload_buffer_lines.py b/shopfloor/tests/test_zone_picking_unload_buffer_lines.py index 0944d96ae2c..b9d765b208e 100644 --- a/shopfloor/tests/test_zone_picking_unload_buffer_lines.py +++ b/shopfloor/tests/test_zone_picking_unload_buffer_lines.py @@ -33,7 +33,9 @@ def test_find_buffer_lines1(self): {"name": f"TEST PKG {i}"} ) self.service._set_destination_package( - line, line.product_uom_qty, dest_package, + line, + line.product_uom_qty, + dest_package, ) # We can unload all the lines no matter which zone we are before unload @@ -57,7 +59,9 @@ def test_find_buffer_lines2(self): {"name": f"TEST PKG {i}"} ) self.service._set_destination_package( - line, line.product_uom_qty, dest_package, + line, + line.product_uom_qty, + dest_package, ) # We can unload all the lines no matter which zone we are before unload @@ -83,7 +87,9 @@ def test_find_buffer_lines3(self): {"name": f"TEST PKG {i}"} ) self.service._set_destination_package( - line, line.product_uom_qty, dest_package, + line, + line.product_uom_qty, + dest_package, ) # Simulate line from picking1 processed by another user for i, line in enumerate(self.picking1.move_line_ids): diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py index 0c18fc7cff1..7983d3e330e 100644 --- a/shopfloor/tests/test_zone_picking_unload_set_destination.py +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -55,7 +55,9 @@ def test_unload_set_destination_no_location_found(self): move_line = self.picking1.move_line_ids # set the destination package self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, ) response = self.service.dispatch( "unload_set_destination", @@ -75,7 +77,9 @@ def test_unload_set_destination_location_not_allowed(self): move_line = self.picking1.move_line_ids # set the destination package self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, ) response = self.service.dispatch( "unload_set_destination", @@ -100,7 +104,9 @@ def test_unload_set_destination_location_move_not_allowed(self): move_line[0].picking_id.location_dest_id = self.packing_sublocation_a # set the destination package self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, ) response = self.service.dispatch( "unload_set_destination", @@ -145,7 +151,9 @@ def test_unload_set_destination_confirm_location(self): ) # set the destination package self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, ) move_line.location_dest_id = packing_sublocation1 response = self.service.dispatch( @@ -183,7 +191,9 @@ def test_unload_set_destination_ok_buffer_empty(self): ) # set the destination package self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, ) response = self.service.dispatch( "unload_set_destination", @@ -218,7 +228,9 @@ def test_unload_set_destination_ok_buffer_not_empty(self): move_lines, self.free_package | self.another_package ): self.service._set_destination_package( - move_line, move_line.product_uom_qty, package_dest, + move_line, + move_line.product_uom_qty, + package_dest, ) free_package_line = move_lines.filtered( lambda l: l.result_package_id == self.free_package @@ -276,7 +288,9 @@ def test_unload_set_destination_partially_available_backorder(self): ) # set the destination package self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, ) 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 c5e2058b111..44bf703cd63 100644 --- a/shopfloor/tests/test_zone_picking_unload_single.py +++ b/shopfloor/tests/test_zone_picking_unload_single.py @@ -21,10 +21,13 @@ def test_unload_scan_pack_wrong_parameters(self): # wrong package ID, and there is still a move line to unload # => get back on 'unload_single' screen self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, ) response = self.service.dispatch( - "unload_scan_pack", params={"package_id": 1234567890, "barcode": "UNKNOWN"}, + "unload_scan_pack", + params={"package_id": 1234567890, "barcode": "UNKNOWN"}, ) completion_info = self.service._actions_for("completion.info") completion_info_popup = completion_info.popup(move_line) @@ -42,7 +45,8 @@ def test_unload_scan_pack_wrong_parameters(self): {"qty_done": 0, "shopfloor_user_id": False, "result_package_id": False} ) response = self.service.dispatch( - "unload_scan_pack", params={"package_id": 1234567890, "barcode": "UNKNOWN"}, + "unload_scan_pack", + params={"package_id": 1234567890, "barcode": "UNKNOWN"}, ) move_lines = self.service._find_location_move_lines() self.assert_response_select_line( @@ -56,7 +60,8 @@ def test_unload_scan_pack_wrong_parameters(self): # => get back on 'start' screen self.pickings.move_lines._do_unreserve() response = self.service.dispatch( - "unload_scan_pack", params={"package_id": 1234567890, "barcode": "UNKNOWN"}, + "unload_scan_pack", + params={"package_id": 1234567890, "barcode": "UNKNOWN"}, ) self.assert_response_start( response, @@ -69,7 +74,9 @@ def test_unload_scan_pack_barcode_match(self): move_line = self.picking1.move_line_ids # set the destination package self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, ) response = self.service.dispatch( "unload_scan_pack", @@ -79,7 +86,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): @@ -91,7 +101,9 @@ def test_unload_scan_pack_barcode_not_match(self): ) # set the destination package self.service._set_destination_package( - move_line, move_line.product_uom_qty, self.free_package, + move_line, + move_line.product_uom_qty, + self.free_package, ) response = self.service.dispatch( "unload_scan_pack", diff --git a/shopfloor/tests/test_zone_picking_zero_check.py b/shopfloor/tests/test_zone_picking_zero_check.py index e0661f7f61b..c192d83728b 100644 --- a/shopfloor/tests/test_zone_picking_zero_check.py +++ b/shopfloor/tests/test_zone_picking_zero_check.py @@ -16,10 +16,12 @@ def setUp(self): def test_is_zero_wrong_parameters(self): response = self.service.dispatch( - "is_zero", params={"move_line_id": 1234567890, "zero": True}, + "is_zero", + params={"move_line_id": 1234567890, "zero": True}, ) 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): @@ -28,11 +30,15 @@ def test_is_zero_is_empty(self): picking_type = self.picking1.picking_type_id move_line = self.picking1.move_line_ids[0] response = self.service.dispatch( - "is_zero", params={"move_line_id": move_line.id, "zero": True}, + "is_zero", + params={"move_line_id": move_line.id, "zero": True}, ) move_lines = self.service._find_location_move_lines() 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): @@ -41,11 +47,15 @@ def test_is_zero_is_not_empty(self): picking_type = self.picking1.picking_type_id move_line = self.picking1.move_line_ids[0] response = self.service.dispatch( - "is_zero", params={"move_line_id": move_line.id, "zero": False}, + "is_zero", + params={"move_line_id": move_line.id, "zero": False}, ) move_lines = self.service._find_location_move_lines() 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( [ @@ -59,6 +69,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 921c808bcbf6ec9f3b3e68435e576cd0512c99be Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Sat, 24 Jul 2021 16:05:24 +0000 Subject: [PATCH 619/710] Translated using Weblate (Spanish (Argentina)) Currently translated at 100.0% (198 of 198 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 | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 492b0989d10..089521b748b 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 13.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2021-04-14 20:46+0000\n" +"PO-Revision-Date: 2021-07-24 18:49+0000\n" "Last-Translator: Ignacio Buioli \n" "Language-Team: none\n" "Language: es_AR\n" @@ -27,6 +27,15 @@ msgid "" "* if a package is scanned, the package is validated against the carrier\n" "* in both cases, if the picking has no carrier the operation fails.\",\n" msgstr "" +"\n" +"Si marca esta casilla, mientras recoge mercancías de una ubicación\n" +"(por ejemplo: selección de zona) el destino establecido funcionará de la " +"siguiente manera:\n" +"\n" +"* si se escanea una ubicación, se crea un nuevo paquete de entrega;\n" +"* si se escanea un paquete, el paquete se valida con el transportista\n" +"* en ambos casos, si el picking no tiene transportista la operación falla. \"" +",\n" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -205,7 +214,7 @@ msgstr "Desde" #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Goods packed into {0.name}" -msgstr "" +msgstr "Mercadería empaquetada en {0.name}" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id @@ -473,7 +482,7 @@ msgstr "No hay paquete válido para seleccionar." #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No value" -msgstr "" +msgstr "Sin valor" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -615,7 +624,7 @@ msgstr "Paquetes" #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Packaging '{}' is not allowed for carrier {}." -msgstr "" +msgstr "El Paquete '{}' no está permitido para el transportista {}." #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 @@ -630,16 +639,18 @@ msgid "" "Pick + Pack mode ON: the picking {0.name} has no carrier set. The system " "couldn't pack goods automatically." msgstr "" +"Modo Pick + Pack ACTIVADO: el picking {0.name} no tiene un transportista " +"configurado. El sistema no podrá empaquetar mercadería automáticamente." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time_is_possible msgid "Pick Pack Same Time Is Possible" -msgstr "" +msgstr "Pick / Pack al mismo tiempo es Posible" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time msgid "Pick and pack at the same time" -msgstr "" +msgstr "Pick y pack al mismo tiempo" #. module: shopfloor #: code:addons/shopfloor/actions/change_package_lot.py:0 @@ -1010,7 +1021,7 @@ msgstr "El paquete %s no puede ser transferido con este escenario." #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "The package %s doesn't contain any product to take." -msgstr "" +msgstr "El paquete %s no contiene un producto para empaquetar." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -1215,6 +1226,8 @@ msgid "" "You scanned a different package with the same product, do you want to change " "pack? Scan it again to confirm" msgstr "" +"Escaneó un paquete diferente con el mismo producto, ¿desea cambiar el " +"paquete? Escanéalo de nuevo para confirmar" #. module: shopfloor #: code:addons/shopfloor/actions/inventory.py:0 From c4131ecf8444902823a45a505a4b075b28aef005 Mon Sep 17 00:00:00 2001 From: Hpar Date: Mon, 26 Jul 2021 09:34:09 +0200 Subject: [PATCH 620/710] Update shopfloor/actions/move_line_search.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix type of priority Co-authored-by: Sébastien Alix --- shopfloor/actions/move_line_search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/actions/move_line_search.py b/shopfloor/actions/move_line_search.py index f789720604f..84f36a87300 100644 --- a/shopfloor/actions/move_line_search.py +++ b/shopfloor/actions/move_line_search.py @@ -104,7 +104,7 @@ def _sort_key_move_lines(order): def counters_for_lines(self, lines): # Not using mapped/filtered to support simple lists and generators - priority_lines = [x for x in lines if x.picking_id.priority == 1] + priority_lines = [x for x in lines if x.picking_id.priority == "1"] return { "lines_count": len(lines), "picking_count": len({x.picking_id.id for x in lines}), From a1165b46ed9f354a9572a6a211eb2d419e13662b Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 29 Jul 2021 09:10:34 +0000 Subject: [PATCH 621/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 47 +++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 47cd13d5aad..576c27ab8e1 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -4,7 +4,7 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 13.0\n" +"Project-Id-Version: Odoo Server 14.0\n" "Report-Msgid-Bugs-To: \n" "Last-Translator: \n" "Language-Team: \n" @@ -25,6 +25,12 @@ msgid "" "* in both cases, if the picking has no carrier the operation fails.\",\n" msgstr "" +#. 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 @@ -176,7 +182,18 @@ msgid "Delivery" msgstr "" #. module: shopfloor +#: 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_stock_inventory__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__display_name msgid "Display Name" msgstr "" @@ -200,7 +217,18 @@ msgid "Goods packed into {0.name}" msgstr "" #. module: shopfloor +#: 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_stock_inventory__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__id msgid "ID" msgstr "" @@ -246,7 +274,18 @@ msgid "Inventory Locations" msgstr "" #. module: shopfloor +#: 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_stock_inventory____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_location____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package____last_update msgid "Last Modified on" msgstr "" @@ -883,11 +922,6 @@ msgstr "" 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,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed #: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_postponed @@ -1092,6 +1126,7 @@ msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_picking +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id msgid "Transfer" msgstr "" From 2fab330ae13e0507d8c8074f3c0691e2b1aaa362 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 29 Jul 2021 09:43:24 +0000 Subject: [PATCH 622/710] [UPD] README.rst --- shopfloor/README.rst | 1 + shopfloor/static/description/index.html | 1 + 2 files changed, 2 insertions(+) diff --git a/shopfloor/README.rst b/shopfloor/README.rst index fbbd202102c..844e66ab58f 100644 --- a/shopfloor/README.rst +++ b/shopfloor/README.rst @@ -105,6 +105,7 @@ Contributors * Alexandre Fayolle * Benoit Guillot * Thierry Ducrest +* Raphaël Reverdy * Jacques-Etienne Baudoux Design diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html index f35d6ab5ff8..082f1631964 100644 --- a/shopfloor/static/description/index.html +++ b/shopfloor/static/description/index.html @@ -459,6 +459,7 @@

    Contributors

  • Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
  • Benoit Guillot <benoit.guillot@akretion.com>
  • Thierry Ducrest <thierry.ducrest@camptocamp.com>
  • +
  • Raphaël Reverdy <raphael.reverdy@akretion.com>
  • Jacques-Etienne Baudoux <je@bcim.be>
  • From eebf311358368cb0ec07516cd1981d2df7f34e4c Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Thu, 29 Jul 2021 09:43:36 +0000 Subject: [PATCH 623/710] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: wms-14.0/wms-14.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor/ --- shopfloor/i18n/ca.po | 45 +++++++++++++++++++++++++++++---- shopfloor/i18n/es_AR.po | 55 +++++++++++++++++++++++++++++++++-------- 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/shopfloor/i18n/ca.po b/shopfloor/i18n/ca.po index 7df7f08c43d..f0d0172f6d9 100644 --- a/shopfloor/i18n/ca.po +++ b/shopfloor/i18n/ca.po @@ -28,6 +28,12 @@ msgid "" "* in both cases, if the picking has no carrier the operation fails.\",\n" msgstr "" +#. 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 @@ -181,7 +187,18 @@ msgid "Delivery" msgstr "Lliurament" #. module: shopfloor +#: 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_stock_inventory__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__display_name msgid "Display Name" msgstr "Nom a mostrar" @@ -205,7 +222,18 @@ msgid "Goods packed into {0.name}" msgstr "" #. module: shopfloor +#: 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_stock_inventory__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__id msgid "ID" msgstr "ID" @@ -253,7 +281,18 @@ msgid "Inventory Locations" msgstr "" #. module: shopfloor +#: 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_stock_inventory____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_location____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package____last_update msgid "Last Modified on" msgstr "" @@ -887,11 +926,6 @@ msgstr "" 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,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed #: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_postponed @@ -1095,6 +1129,7 @@ msgstr "" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_picking +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id msgid "Transfer" msgstr "" diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 089521b748b..11543a265ee 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -34,8 +34,14 @@ msgstr "" "\n" "* si se escanea una ubicación, se crea un nuevo paquete de entrega;\n" "* si se escanea un paquete, el paquete se valida con el transportista\n" -"* en ambos casos, si el picking no tiene transportista la operación falla. \"" -",\n" +"* en ambos casos, si el picking no tiene transportista la operación falla. " +"\",\n" + +#. module: shopfloor +#: code:addons/shopfloor/services/forms/form_mixin.py:0 +#, python-format +msgid "%s updated." +msgstr "%s actualizado." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -190,7 +196,18 @@ msgid "Delivery" msgstr "Entrega" #. module: shopfloor +#: 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_stock_inventory__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__display_name msgid "Display Name" msgstr "Mostrar Nombre" @@ -217,7 +234,18 @@ msgid "Goods packed into {0.name}" msgstr "Mercadería empaquetada en {0.name}" #. module: shopfloor +#: 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_stock_inventory__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__id msgid "ID" msgstr "ID" @@ -268,7 +296,18 @@ msgid "Inventory Locations" msgstr "Ubicaciones de Inventario" #. module: shopfloor +#: 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_stock_inventory____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_location____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package____last_update msgid "Last Modified on" msgstr "Última Modificación el" @@ -930,11 +969,6 @@ msgstr "Movimiento de Inventario" msgid "Stock Package Level" 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 "Inventario de la Entrega" - #. 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 @@ -1151,6 +1185,7 @@ msgstr "Peso Total" #. module: shopfloor #: model:ir.model,name:shopfloor.model_stock_picking +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id msgid "Transfer" msgstr "Transferencia" @@ -1270,6 +1305,9 @@ msgstr "{} no es un paquete de destino válido." msgid "{} {} put in {}" msgstr "{} {} poner en {}" +#~ msgid "Stock Picking" +#~ msgstr "Inventario de la Entrega" + #~ msgid "Created by" #~ msgstr "Creado por" @@ -1298,9 +1336,6 @@ msgstr "{} {} poner en {}" #~ msgid "Packaging {} does not match carrier {}." #~ msgstr "El Empaquetado {} no coincide con el transportista {}." -#~ msgid "%s updated." -#~ msgstr "%s actualizado." - #~ msgid "Active" #~ msgstr "Activo" From 4a60aba5f7f7c4caf06eb9da8b54da9e707f18ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 1 Jul 2021 11:38:31 +0200 Subject: [PATCH 624/710] [ADD] shopfloor_delivery_shipment Shopfloor scenario to manage the delivery process based on shipment advices. It is based on the `shipment_advice` module. --- shopfloor/actions/data.py | 2 ++ shopfloor/actions/schema.py | 6 ++++++ shopfloor/models/stock_picking.py | 15 +++++++++++++++ shopfloor/tests/test_actions_data.py | 2 ++ shopfloor/tests/test_actions_data_detail.py | 2 ++ 5 files changed, 27 insertions(+) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index a68822f0448..11034c0eed1 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -44,6 +44,8 @@ def _picking_parser(self): ("partner_id:partner", self._partner_parser), ("carrier_id:carrier", self._simple_record_parser()), "move_line_count", + "package_level_count", + "bulk_line_count", "total_weight:weight", "scheduled_date", ] diff --git a/shopfloor/actions/schema.py b/shopfloor/actions/schema.py index 84cd7fe32cd..c3b07c8eb9d 100644 --- a/shopfloor/actions/schema.py +++ b/shopfloor/actions/schema.py @@ -14,6 +14,12 @@ def picking(self): "origin": {"type": "string", "nullable": True, "required": False}, "note": {"type": "string", "nullable": True, "required": False}, "move_line_count": {"type": "integer", "nullable": True, "required": True}, + "package_level_count": { + "type": "integer", + "nullable": True, + "required": True, + }, + "bulk_line_count": {"type": "integer", "nullable": True, "required": True}, "weight": {"required": True, "nullable": True, "type": "float"}, "partner": self._schema_dict_of(self._simple_record()), "carrier": self._schema_dict_of(self._simple_record(), required=False), diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index fe27b7b4eb4..bb11e54c2b5 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -14,6 +14,14 @@ class StockPicking(models.Model): compute="_compute_picking_info", help="Technical field. Indicates number of move lines included.", ) + package_level_count = fields.Integer( + compute="_compute_picking_info", + help="Technical field. Indicates number of package_level included.", + ) + bulk_line_count = fields.Integer( + compute="_compute_picking_info", + help="Technical field. Indicates number of move lines without package included.", + ) @api.depends( "move_line_ids", "move_line_ids.product_qty", "move_line_ids.product_id.weight" @@ -24,6 +32,13 @@ def _compute_picking_info(self): { "total_weight": item._calc_weight(), "move_line_count": len(item.move_line_ids), + "package_level_count": len(item.package_level_ids), + # NOTE: not based on 'move_line_ids_without_package' field + # on purpose as it also takes into account the + # 'Move entire packs' option from the picking type. + "bulk_line_count": len( + item.move_line_ids.filtered(lambda ml: not ml.package_level_id) + ), } ) diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 7c60ed8f11f..1231505077e 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -101,6 +101,8 @@ def test_data_picking(self): expected = { "id": self.picking.id, "move_line_count": 4, + "package_level_count": 2, + "bulk_line_count": 2, "name": self.picking.name, "note": "read me", "origin": "created by test", diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index a694ee5970f..9d3570de4ad 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -108,6 +108,8 @@ def test_data_picking(self): expected = { "id": picking.id, "move_line_count": 4, + "package_level_count": 2, + "bulk_line_count": 2, "name": picking.name, "note": "read me", "origin": "created by test", From 28bb3647a27b522ed8edd27b8d94ccfb38aeb5fb Mon Sep 17 00:00:00 2001 From: oca-travis Date: Fri, 15 Oct 2021 06:42:45 +0000 Subject: [PATCH 625/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 576c27ab8e1..4db0726facc 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -110,6 +110,11 @@ msgstr "" msgid "Bin %s doesn't exist" msgstr "" +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__bulk_line_count +msgid "Bulk Line Count" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -529,6 +534,11 @@ msgstr "" msgid "Operation's already running. Would you like to take it over?" msgstr "" +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__package_level_count +msgid "Package Level Count" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -937,6 +947,17 @@ msgstr "" msgid "Technical field. Indicates number of move lines included." msgstr "" +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__bulk_line_count +msgid "" +"Technical field. Indicates number of move lines without package included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__package_level_count +msgid "Technical field. Indicates number of package_level included." +msgstr "" + #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count msgid "Technical field. Indicates number of transfers included." From 74b0c5d0d648103bf43cfbb1bf7bd492ef8baefc Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Fri, 15 Oct 2021 07:22:52 +0000 Subject: [PATCH 626/710] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: wms-14.0/wms-14.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor/ --- shopfloor/i18n/ca.po | 21 +++++++++++++++++++++ shopfloor/i18n/es_AR.po | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/shopfloor/i18n/ca.po b/shopfloor/i18n/ca.po index f0d0172f6d9..451da7b9ce5 100644 --- a/shopfloor/i18n/ca.po +++ b/shopfloor/i18n/ca.po @@ -113,6 +113,11 @@ msgstr "Línia de transferència per lots completada" msgid "Bin %s doesn't exist" msgstr "Compartiment %s no existeix" +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__bulk_line_count +msgid "Bulk Line Count" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -535,6 +540,11 @@ msgstr "" msgid "Operation's already running. Would you like to take it over?" msgstr "" +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__package_level_count +msgid "Package Level Count" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -941,6 +951,17 @@ msgstr "" msgid "Technical field. Indicates number of move lines included." msgstr "" +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__bulk_line_count +msgid "" +"Technical field. Indicates number of move lines without package included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__package_level_count +msgid "Technical field. Indicates number of package_level included." +msgstr "" + #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count msgid "Technical field. Indicates number of transfers included." diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 11543a265ee..343c7456abe 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -122,6 +122,11 @@ msgstr "Línea de Transferencia por lotes hecha" msgid "Bin %s doesn't exist" msgstr "Compartimento %s no existe" +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__bulk_line_count +msgid "Bulk Line Count" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -559,6 +564,11 @@ msgstr "Operación ya procesada." msgid "Operation's already running. Would you like to take it over?" msgstr "La operación ya está en marcha. ¿Le gustaría hacerse cargo?" +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__package_level_count +msgid "Package Level Count" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 #, python-format @@ -986,6 +996,17 @@ msgstr "" msgid "Technical field. Indicates number of move lines included." 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__bulk_line_count +msgid "" +"Technical field. Indicates number of move lines without package included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__package_level_count +msgid "Technical field. Indicates number of package_level included." +msgstr "" + #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count msgid "Technical field. Indicates number of transfers included." From 923c42c4d2cf37bc2166f9bc35e1ae648e163e37 Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Tue, 19 Oct 2021 21:03:37 +0000 Subject: [PATCH 627/710] Translated using Weblate (Spanish (Argentina)) Currently translated at 100.0% (202 of 202 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 | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 343c7456abe..5ffc07845ea 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 13.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2021-07-24 18:49+0000\n" +"PO-Revision-Date: 2021-10-19 23:35+0000\n" "Last-Translator: Ignacio Buioli \n" "Language-Team: none\n" "Language: es_AR\n" @@ -125,7 +125,7 @@ msgstr "Compartimento %s no existe" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking__bulk_line_count msgid "Bulk Line Count" -msgstr "" +msgstr "Cuenta de Líneas de Bultos" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -567,7 +567,7 @@ msgstr "La operación ya está en marcha. ¿Le gustaría hacerse cargo?" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_picking__package_level_count msgid "Package Level Count" -msgstr "" +msgstr "Cuental del Nivel de Paquete" #. module: shopfloor #: code:addons/shopfloor/services/checkout.py:0 @@ -1001,11 +1001,12 @@ msgstr "Campo técnico. Indica el número de líneas de movimiento incluidas." msgid "" "Technical field. Indicates number of move lines without package included." msgstr "" +"Campo técnico. Indica el número de lineas de movimiento sin paquete incluído." #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking__package_level_count msgid "Technical field. Indicates number of package_level included." -msgstr "" +msgstr "Campo técnico. Indica el número del package_level incluído." #. module: shopfloor #: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count From 5c7bd677f86924906fcda6bbcd1b12275c76487e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 8 Sep 2021 16:01:40 +0200 Subject: [PATCH 628/710] [FIX] shopfloor, single pack transfer: user error if no location is defined on the package --- shopfloor/services/single_pack_transfer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 32e7489ee81..928919d42ac 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -85,7 +85,9 @@ def start(self, barcode, confirmation=False): self.msg_store.package_not_found_for_barcode(barcode) ) - if not self.is_src_location_valid(package.location_id): + if not package.location_id or not self.is_src_location_valid( + package.location_id + ): return self._response_for_start( message=self.msg_store.package_not_allowed_in_src_location( barcode, picking_types From 9e15e1229f133f155d76edc2674b21e982b036b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 8 Sep 2021 16:10:59 +0200 Subject: [PATCH 629/710] [FIX] shopfloor, fix line postponing --- shopfloor/models/priority_postpone_mixin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shopfloor/models/priority_postpone_mixin.py b/shopfloor/models/priority_postpone_mixin.py index d501a117fdb..cbe64cdfbf9 100644 --- a/shopfloor/models/priority_postpone_mixin.py +++ b/shopfloor/models/priority_postpone_mixin.py @@ -22,6 +22,8 @@ class PriorityPostponeMixin(models.AbstractModel): def _get_max_shopfloor_priority(self, records): self.ensure_one() + if not records: + return 0 return max(rec.shopfloor_priority for rec in records) def shopfloor_postpone(self, *recordsets): From fb60b04f1423244f5c6dc45e1bce3514625ef127 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Wed, 19 May 2021 10:39:24 +0200 Subject: [PATCH 630/710] [130][IMP] shopfloor mobile carrier display In the backend add the carrier computed from the module `stock_picking_delivery_link` into the picking data send. In the frontend: checkout, set the color of the carrier card in green * Add the carrier on the cluster picking last screen. * On the checkout and the cluster picking improve the carrier display by using the one added in the backend if needed. * Set the carrier card in green. --- shopfloor/actions/data.py | 1 + shopfloor/actions/schema.py | 1 + shopfloor/tests/test_actions_data.py | 1 + shopfloor/tests/test_actions_data_detail.py | 1 + 4 files changed, 4 insertions(+) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 11034c0eed1..2e9523d71c6 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -43,6 +43,7 @@ def _picking_parser(self): "note", ("partner_id:partner", self._partner_parser), ("carrier_id:carrier", self._simple_record_parser()), + ("ship_carrier_id:ship_carrier", self._simple_record_parser()), "move_line_count", "package_level_count", "bulk_line_count", diff --git a/shopfloor/actions/schema.py b/shopfloor/actions/schema.py index c3b07c8eb9d..d80ddf63f03 100644 --- a/shopfloor/actions/schema.py +++ b/shopfloor/actions/schema.py @@ -23,6 +23,7 @@ def picking(self): "weight": {"required": True, "nullable": True, "type": "float"}, "partner": self._schema_dict_of(self._simple_record()), "carrier": self._schema_dict_of(self._simple_record(), required=False), + "ship_carrier": self._schema_dict_of(self._simple_record(), required=False), "scheduled_date": {"type": "string", "nullable": False, "required": True}, } diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index 1231505077e..99412286dd7 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -109,6 +109,7 @@ def test_data_picking(self): "weight": 110.0, "partner": {"id": self.customer.id, "name": self.customer.name}, "carrier": {"id": carrier.id, "name": carrier.name}, + "ship_carrier": None, } self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-08-03") self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py index 9d3570de4ad..4cb2733e7c7 100644 --- a/shopfloor/tests/test_actions_data_detail.py +++ b/shopfloor/tests/test_actions_data_detail.py @@ -113,6 +113,7 @@ def test_data_picking(self): "name": picking.name, "note": "read me", "origin": "created by test", + "ship_carrier": None, "weight": 110.0, "partner": {"id": self.customer.id, "name": self.customer.name}, "carrier": {"id": picking.carrier_id.id, "name": picking.carrier_id.name}, From cecec5dfdb5077b8549cc68d84de48f97abea48d Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 3 Nov 2021 14:09:54 +0000 Subject: [PATCH 631/710] shopfloor 14.0.1.0.1 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 9a8330b6917..19cc70f5e14 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "14.0.1.0.0", + "version": "14.0.1.0.1", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From f70f1456c6fcee829851d6663439640c882349c5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 16 Nov 2021 12:08:43 +0100 Subject: [PATCH 632/710] shopfloor: fix test_user --- shopfloor/tests/__init__.py | 1 + shopfloor/tests/test_menu_base.py | 39 +++++++++--------- shopfloor/tests/test_user.py | 68 +++++++++++++++++++++++++------ 3 files changed, 77 insertions(+), 31 deletions(-) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 77f29f70803..a617d085463 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -67,3 +67,4 @@ from . import test_scan_anything from . import test_stock_split from . import test_picking_form +from . import test_user diff --git a/shopfloor/tests/test_menu_base.py b/shopfloor/tests/test_menu_base.py index 8d4ce672f61..cc8a7f93001 100644 --- a/shopfloor/tests/test_menu_base.py +++ b/shopfloor/tests/test_menu_base.py @@ -35,25 +35,26 @@ def setUp(self): def _data_for_menu_item(self, menu, **kw): data = super()._data_for_menu_item(menu, **kw) - expected_counters = kw.get("expected_counters") or {} - data.update( - { - "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) + if menu.picking_type_ids: + data.update( + { + "picking_types": [ + {"id": picking_type.id, "name": picking_type.name} + for picking_type in menu.picking_type_ids + ], + } + ) + expected_counters = kw.get("expected_counters") or {} + 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 diff --git a/shopfloor/tests/test_user.py b/shopfloor/tests/test_user.py index 736bb86e1ae..4b2608645ed 100644 --- a/shopfloor/tests/test_user.py +++ b/shopfloor/tests/test_user.py @@ -1,14 +1,51 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import mock + from .test_menu_base import CommonMenuCase class UserCase(CommonMenuCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Hack to isolate data for testing. + # Menu items can be added by any third party module + # and we want to make sure we work only w/ the subset described here. + ref = cls.env.ref + xids = [ + "shopfloor.shopfloor_menu_single_pallet_transfer", + "shopfloor.shopfloor_menu_zone_picking", + "shopfloor.shopfloor_menu_cluster_picking", + "shopfloor.shopfloor_menu_checkout", + "shopfloor.shopfloor_menu_delivery", + "shopfloor.shopfloor_menu_location_content_transfer", + "shopfloor_base.shopfloor_menu_demo_1", + ] + menu_model = cls.env["shopfloor.menu"] + + def mocked_search(self, *args, **kwargs): + return mocked_search.orig_search(*args, **kwargs).filtered( + lambda x: x.id in mocked_search.limited_menu_item_ids + ) + + mocked_search.orig_search = menu_model.search + mocked_search.limited_menu_item_ids = [ref(x).id for x in xids] + + cls.patcher = mock.patch.object(type(menu_model), "search", mocked_search) + cls.patcher.start() + + @classmethod + def tearDownClass(cls): + cls.patcher.stop() + super().tearDownClass() + @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") + ref = cls.env.ref + cls.profile = ref("shopfloor_base.profile_demo_1") + cls.profile2 = ref("shopfloor_base.profile_demo_2") def setUp(self): super().setUp() @@ -17,26 +54,33 @@ def setUp(self): def test_menu_no_profile(self): """Request /user/menu""" - # Simulate the client asking the menu + # Simulate the client asking the menu w/out profile -> no menu + self.service.work.profile = self.env["shopfloor.menu"].browse() response = self.service.dispatch("menu") - menus = self.env["shopfloor.menu"].search([]) self.assert_response( response, - data={"menus": [self._data_for_menu_item(menu) for menu in menus]}, + data={"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_id = self.profile - (menus - menu).profile_id = self.profile2 + """Request /user/menu w/ a specific profile but no picking types""" + # Current profile 1 matches only this menu item + expected_menu = self.env.ref("shopfloor_base.shopfloor_menu_demo_1") response = self.service.dispatch("menu") self.assert_response( response, - data={"menus": [self._data_for_menu_item(menu)]}, + data={"menus": [self._data_for_menu_item(expected_menu)]}, + ) + + # now all the rest but the 1st one + self.service.work.profile = self.profile2 + menus = self.env["shopfloor.menu"].sudo().search([]) - expected_menu + menus.sudo().profile_id = self.profile2 + response = self.service.dispatch("menu") + self.assert_response( + response, + data={"menus": [self._data_for_menu_item(menu) for menu in menus]}, ) def test_user_info(self): From ba20de37b53dedafdedf910432b47c27f1d53e82 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 17 Nov 2021 13:29:18 +0000 Subject: [PATCH 633/710] shopfloor 14.0.1.0.2 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 19cc70f5e14..059be997e9c 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "14.0.1.0.1", + "version": "14.0.1.0.2", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 4a5465551663489f3bd889435720e857dcaaf1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 29 Mar 2021 11:20:42 +0200 Subject: [PATCH 634/710] shopfloor: new scenario 'manual product transfer' New scenario allowing to move a product/lot from one source location to another, similar to the location content transfer but limited to a product/lot. --- shopfloor/__manifest__.py | 2 + shopfloor/actions/message.py | 30 + shopfloor/actions/stock.py | 9 + shopfloor/data/shopfloor_scenario_data.xml | 10 + shopfloor/demo/shopfloor_menu_demo.xml | 9 + shopfloor/demo/stock_picking_type_demo.xml | 15 + .../manual_product_transfer_diag_seq.plantuml | 41 ++ .../docs/manual_product_transfer_diag_seq.png | Bin 0 -> 58262 bytes shopfloor/models/stock_location.py | 7 + shopfloor/services/__init__.py | 1 + .../services/location_content_transfer.py | 11 +- shopfloor/services/manual_product_transfer.py | 662 ++++++++++++++++++ shopfloor/services/single_pack_transfer.py | 12 +- shopfloor/tests/__init__.py | 6 + .../test_manual_product_transfer_base.py | 115 +++ ...anual_product_transfer_confirm_quantity.py | 271 +++++++ .../test_manual_product_transfer_misc.py | 47 ++ ...duct_transfer_scan_destination_location.py | 66 ++ ...st_manual_product_transfer_scan_product.py | 92 +++ .../test_manual_product_transfer_start.py | 44 ++ 20 files changed, 1433 insertions(+), 17 deletions(-) create mode 100644 shopfloor/docs/manual_product_transfer_diag_seq.plantuml create mode 100644 shopfloor/docs/manual_product_transfer_diag_seq.png create mode 100644 shopfloor/services/manual_product_transfer.py create mode 100644 shopfloor/tests/test_manual_product_transfer_base.py create mode 100644 shopfloor/tests/test_manual_product_transfer_confirm_quantity.py create mode 100644 shopfloor/tests/test_manual_product_transfer_misc.py create mode 100644 shopfloor/tests/test_manual_product_transfer_scan_destination_location.py create mode 100644 shopfloor/tests/test_manual_product_transfer_scan_product.py create mode 100644 shopfloor/tests/test_manual_product_transfer_start.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 059be997e9c..16281cf112c 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -39,6 +39,8 @@ "stock_storage_type", # TODO: used for picking.carrier_id detail info # and to validate packaging/carrier in checkout scenario + # OCA / stock-logistics-workflow + "stock_restrict_lot", # This must be an optional dep "delivery", # OCA / product-attribute diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 238696a4d88..d2a28d2744f 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -193,6 +193,18 @@ def no_package_or_lot_for_barcode(self, barcode): "body": _("No package or lot found for barcode {}.").format(barcode), } + def no_product_for_barcode(self, barcode): + return { + "message_type": "error", + "body": _("No product found for {}").format(barcode), + } + + def wrong_product(self): + return { + "message_type": "error", + "body": _("Wrong product."), + } + def no_lot_for_barcode(self, barcode): return { "message_type": "error", @@ -205,6 +217,12 @@ def lot_on_wrong_product(self, barcode): "body": _("Lot {} is for another product.").format(barcode), } + def wrong_lot(self): + return { + "message_type": "error", + "body": _("Wrong lot."), + } + def several_lots_in_location(self, location): return { "message_type": "warning", @@ -219,6 +237,12 @@ def several_products_in_location(self, location): ), } + def no_product_in_location(self, location): + return { + "message_type": "error", + "body": _("No product found in {}").format(location.name), + } + def no_pending_operation_for_pack(self, pack): return { "message_type": "error", @@ -551,3 +575,9 @@ def picking_without_carrier_cannot_pack(self, picking): "The system couldn't pack goods automatically." ).format(picking), } + + def qty_exceeds_initial_qty(self): + return { + "message_type": "error", + "body": _("This quantity exceeds the initial one."), + } diff --git a/shopfloor/actions/stock.py b/shopfloor/actions/stock.py index f5ed66b9e35..4264c7dc8be 100644 --- a/shopfloor/actions/stock.py +++ b/shopfloor/actions/stock.py @@ -60,3 +60,12 @@ def put_package_level_in_move(self, package_level): # 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) + + def no_putaway_available(self, picking_types, move_lines): + """Returns `True` if no putaway destination has been computed for one + of the given move lines. + """ + base_locations = 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) diff --git a/shopfloor/data/shopfloor_scenario_data.xml b/shopfloor/data/shopfloor_scenario_data.xml index a4128371a07..3b6ad81e338 100644 --- a/shopfloor/data/shopfloor_scenario_data.xml +++ b/shopfloor/data/shopfloor_scenario_data.xml @@ -41,6 +41,16 @@ "allow_create_moves": true, "allow_unreserve_other_moves": true, "allow_ignore_no_putaway_available": true +} + + + + Manual product transfer + manual_product_transfer + +{ + "allow_unreserve_other_moves": true, + "allow_ignore_no_putaway_available": true } diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 5360865ca5f..dc57c1ba2e4 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -55,4 +55,13 @@ eval="[(4, ref('shopfloor.picking_type_location_content_transfer_demo'))]" /> + + Manual Product Transfer + 70 + + + diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index c912a1087d5..b76b3db1c6e 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -90,4 +90,19 @@ + + Manual Product Transfer + MPT + + + + + + + + + internal + + + diff --git a/shopfloor/docs/manual_product_transfer_diag_seq.plantuml b/shopfloor/docs/manual_product_transfer_diag_seq.plantuml new file mode 100644 index 00000000000..041779ecf7b --- /dev/null +++ b/shopfloor/docs/manual_product_transfer_diag_seq.plantuml @@ -0,0 +1,41 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml delivery_diag_seq.plantuml +# + +@startuml + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Manual Product Transfer scenario + +== /scan_source_location == +start -> scan_product: **/scan_source_location**(barcode) + +== /scan_product == +scan_product -> confirm_quantity: **/scan_product**(location_id, barcode) +scan_product -> scan_destination_location: **/scan_product**(location_id, barcode) \n(session recovering if there were existing move lines linked to the current operator for this source location and product) + +== /set_quantity (optional) == +confirm_quantity -> confirm_quantity: **/set_quantity**(location_id, product_id, quantity, lot_id=None) + +== /confirm_quantity == +confirm_quantity -> scan_destination_location: **/confirm_quantity**(location_id, product_id, quantity, lot_id=None, barcode=None, confirm=False) \n(create the move and assign it) + +== /scan_destination_location == +scan_destination_location -> start: **/scan_destination_location**(move_id, barcode) \n(post the move) + +@enduml diff --git a/shopfloor/docs/manual_product_transfer_diag_seq.png b/shopfloor/docs/manual_product_transfer_diag_seq.png new file mode 100644 index 0000000000000000000000000000000000000000..3370390dcfdcde4e03dc59bdce5c05af57640da3 GIT binary patch literal 58262 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", 0)] + ) + if not quants: + return self._response_for_start( + message=self.msg_store.location_empty(location) + ) + return self._response_for_scan_product(location) + + def _find_user_move_lines_domain(self, location, product, lot=None): + domain = [ + ("location_id", "=", location.id), + # ("qty_done", ">", 0), + ("state", "not in", ("cancel", "done")), + ("shopfloor_user_id", "=", self.env.user.id), + ("picking_id.picking_type_id", "in", self.picking_types.ids), + ("product_id", "=", product.id), + ] + if lot: + domain.append(("lot_id", "=", lot.id)) + return domain + + def _find_user_move_lines(self, location, product, lot=None): + """Find move lines processed by the current user.""" + domain = self._find_user_move_lines_domain(location, product, lot) + return self.env["stock.move.line"].search(domain) + + def _find_location_move_lines_domain( + self, location, product, lot=None, with_qty_done=False + ): + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", product.id), + ("state", "in", ("assigned", "partially_available")), + ("shopfloor_user_id", "=", False), + ] + if lot: + domain.append(("lot_id", "=", lot.id)) + if with_qty_done: + domain.append(("qty_done", ">", 0)) + else: + domain.append(("qty_done", "=", 0)) + return domain + + def _find_location_move_lines( + self, location, product, lot=None, with_qty_done=False + ): + """Find existing move lines in progress related to the source location + but not linked to any user. + """ + return self.env["stock.move.line"].search( + self._find_location_move_lines_domain(location, product, lot, with_qty_done) + ) + + def _get_product_qty(self, location, product, lot=None, free=False): + """Returns the available quantity for a given location/product/lot. + + The available quantity returned depends on the + "Allow to process reserved quantities" option, if this one is enabled + then the method will return the quantity on hands, not taking into + account the reserved quantities (to be able to unreserve them if needed). + Otherwise the method will return the actual free quantity. + """ + product = product.with_context( + location=location.id, lot_id=lot.id if lot else None + ) + if free: + return product.free_qty + return product.qty_available + + def _get_product_qty_processed(self, location, product, lot=None): + """Returns the current quantity processed (qty done) by users for the + given location/product/lot among reserved quantities (existing move lines). + """ + move_lines = self._find_location_move_lines( + location, product, lot, with_qty_done=True + ) + return sum( + [ + line.product_id.uom_id._compute_quantity( + line.qty_done, line.product_uom_id, rounding_method="HALF-UP", + ) + for line in move_lines + ] + ) + + def _get_initial_qty(self, location, product, lot=None): + """Compute the initial quantity for the given location/product/lot.""" + if self.work.menu.allow_unreserve_other_moves: + # If the "Allow to process reserved quantities" is enabled, the + # initial qty is the available qty (reservation included) from which + # we substract the qty currently processed (qty_done on move lines) + current_qty = self._get_product_qty(location, product, lot, free=False) + done_qty = self._get_product_qty_processed(location, product, lot) + else: + # Otherwise we simply use the free qty (without any reservation) + # available in the location as the initial qty + current_qty = self._get_product_qty(location, product, lot, free=True) + done_qty = 0 + return current_qty - done_qty + + def scan_product(self, location_id, barcode): + """Scan a product or a lot existing in the source location. + + If there is already some work in progress for the scanned product or lot, + restore it. + + Transitions: + * confirm_quantity: product or lot was found in the source location + * scan_product: scanned product or lot is wrong (error) + """ + location = self.env["stock.location"].browse(location_id).exists() + if not location: + return self._response_for_start(message=self.msg_store.record_not_found()) + search = self._actions_for("search") + initial_qty = None + # Search by product first + product = search.product_from_scan(barcode) + if product: + if product.tracking != "none": + return self._response_for_scan_product( + location, + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + existing_move_lines = self._find_user_move_lines(location, product) + picking = first(existing_move_lines.picking_id) + if existing_move_lines: + return self._response_for_scan_destination_location( + picking, + picking.move_line_ids & existing_move_lines, + message=self.msg_store.recovered_previous_session(), + ) + # Search by lot + lot = search.lot_from_scan(barcode) + if lot: + product = lot.product_id + existing_move_lines = self._find_user_move_lines( + location, lot.product_id, lot=lot + ) + picking = first(existing_move_lines.picking_id) + if existing_move_lines: + return self._response_for_scan_destination_location( + picking, + picking.move_line_ids & existing_move_lines, + message=self.msg_store.recovered_previous_session(), + ) + # Compute the initial quantity + initial_qty = self._get_initial_qty(location, product, lot) + # No product available quantity to move + if ( + product + and lot + and float_is_zero(initial_qty, precision_rounding=product.uom_id.rounding) + ): + return self._response_for_scan_product( + location, message=self.msg_store.no_product_in_location(location) + ) + # Available quantity to move + if initial_qty: + return self._response_for_confirm_quantity( + location, product, initial_qty, lot + ) + # No product or lot found + return self._response_for_scan_product( + location, message=self.msg_store.barcode_not_found() + ) + + def _check_quantity(self, location, product, lot, quantity): + """Check that the input quantity does not exceeds the initial quantity. + + Returns a response with an error message if applicable. + """ + initial_qty = self._get_initial_qty(location, product, lot) + if ( + float_compare( + quantity, initial_qty, precision_rounding=product.uom_id.rounding + ) + == 1 + ): + return self._response_for_confirm_quantity( + location, + product, + initial_qty, + lot, + message=self.msg_store.qty_exceeds_initial_qty(), + ) + + def set_quantity(self, location_id, product_id, quantity, lot_id=None): + """Allows to change the initial quantity to move. + + Transitions: + * confirm_quantity: the move is updated with the new quantity + * confirm_quantity + error message: the new quantity exceeds the initial qty + """ + location = self.env["stock.location"].browse(location_id).exists() + product = self.env["product.product"].browse(product_id).exists() + lot = None + if lot_id: + lot = self.env["stock.production.lot"].browse(lot_id).exists() + # Get back on the start screen if record IDs do not exist + if not location or not product or (not lot if lot_id else False): + return self._response_for_start(message=self.msg_store.record_not_found()) + response = self._check_quantity(location, product, lot, quantity) + if response: + return response + return self._response_for_confirm_quantity(location, product, quantity, lot) + + def _create_move_from_location(self, location, product, quantity, lot=None): + picking_type = self.picking_types + move_vals = { + "name": product.name, + "company_id": picking_type.company_id.id, + "product_id": product.id, + "product_uom": product.uom_id.id, + "product_uom_qty": quantity, + "location_id": location.id, + "location_dest_id": picking_type.default_location_dest_id.id, + "origin": self.work.menu.name, + "picking_type_id": picking_type.id, + } + if lot: + move_vals["restrict_lot_id"] = lot.id + move = self.env["stock.move"].create(move_vals) + move._action_confirm(merge=False) + move.with_context({"force_reservation": True})._action_assign() + assert move.state == "assigned", "The reservation of quantities has failed" + move.move_line_ids.shopfloor_user_id = self.env.user + for line in move.move_line_ids: + line.qty_done = line.product_uom_qty + return move + + def confirm_quantity( + self, + location_id, + product_id, + quantity, + lot_id=None, + barcode=None, + confirm=False, + ): + """Confirm the quantity to move by scanning the product/lot a second + time (`barcode`) or by clicking the button (`confirm`). + + Transitions: + * scan_destination_location: quantity is confirmed for the current product/lot + * confirm_quantity: scanned product or lot is wrong (error) + """ + location = self.env["stock.location"].browse(location_id).exists() + product = self.env["product.product"].browse(product_id).exists() + lot = None + if lot_id: + lot = self.env["stock.production.lot"].browse(lot_id).exists() + # Get back on the start screen if record IDs do not exist + if not location or not product or (not lot if lot_id else False): + return self._response_for_start(message=self.msg_store.record_not_found()) + # Check input barcode + if barcode: + response = self._confirm_quantity_check_barcode( + location, product, quantity, lot, barcode + ) + if response: + return response + confirm = True + # If not confirmed, get back on the same screen (should not happen, this + # means no barcode is scanned or no confirm button is clicked) + if not confirm: + return self._response_for_confirm_quantity( + location, product, quantity, lot, + ) + # Check the input quantity + initial_qty = self._get_initial_qty(location, product, lot) + response = self._check_quantity(location, product, lot, quantity) + if response: + return response + savepoint = self._actions_for("savepoint").new() + stock = self._actions_for("stock") + # Quantity has been confirmed, try to create the move + # 1. Check there is enough stock in the location to move, otherwise + # unreserve existing moves if applicable + current_qty = self._get_product_qty(location, product, lot, free=True) + initial_qty = self._get_initial_qty(location, product, lot) + unreserved_moves = self.env["stock.move"].browse() + if self.work.menu.allow_unreserve_other_moves and ( + # FIXME use float_compare + current_qty + < quantity + <= initial_qty + ): + # If available qty (qty non reserved) < quantity set => Unreserve + # other moves for this product and location + move_lines = self._find_location_move_lines(location, product, lot) + move_lines, unreserved_moves, response = self._unreserve_other_lines( + location, move_lines + ) + if response: + savepoint.rollback() + return response + # 2. Create the move and assign it + move = self._create_move_from_location(location, product, quantity, lot) + # 3. If the "Ignore transfers when no put-away is available" is enabled + # and no putaway has been computed, rollback the creation of the move + if self.work.menu.ignore_no_putaway_available and stock.no_putaway_available( + self.picking_types, move.move_line_ids + ): + savepoint.rollback() + return self._response_for_confirm_quantity( + location, + product, + quantity, + lot, + message=self.msg_store.no_putaway_destination_available(), + ) + # 4. If moves were unreserved -> Reserve them back again + unreserved_moves.with_context({"force_reservation": True})._action_assign() + savepoint.release() + return self._response_for_scan_destination_location( + move.picking_id, move.move_line_ids + ) + + def _confirm_quantity_check_barcode( + self, location, product, quantity, lot, barcode + ): + search = self._actions_for("search") + # Check if the lot matches if any + if lot: + scanned_lot = search.lot_from_scan(barcode) + # Barcode is not a lot + if not scanned_lot: + return self._response_for_confirm_quantity( + location, + product, + quantity, + lot, + message=self.msg_store.no_lot_for_barcode(barcode), + ) + # Barcode is a lot but doesn't match the processed one + if lot != scanned_lot: + return self._response_for_confirm_quantity( + location, product, quantity, lot, message=self.msg_store.wrong_lot() + ) + return + # Search by product + scanned_product = search.product_from_scan(barcode) + # Barcode is not a product + if not scanned_product: + return self._response_for_confirm_quantity( + location, + product, + quantity, + lot, + message=self.msg_store.no_product_for_barcode(barcode), + ) + # Barcode is a product but doesn't match the processed one + if product != scanned_product: + return self._response_for_confirm_quantity( + location, product, quantity, lot, message=self.msg_store.wrong_product() + ) + + # FIXME copy pasted from location content transfer, put it elsewhere? + 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, self.env["stock.move"].browse(), None) + unreserved_moves = move_lines.move_id + location_move_lines = self.env["stock.move.line"].search( + [ + ("location_id", "=", location.id), + ("state", "in", ("assigned", "partially_available")), + ] + ) + 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_destination_location(self, move_line_ids, barcode): + """Scan the destination location and post the move. + + Transitions: + * start: move has been posted successfully + * scan_destination_location: scanned location is wrong (error) + """ + move_lines = self.env["stock.move.line"].browse(move_line_ids).exists() + # Get back on the start screen if record IDs do not exist + if not move_lines or move_lines.ids != move_line_ids: + return self._response_for_start(message=self.msg_store.record_not_found()) + search = self._actions_for("search") + # Check the scanned destination + location = search.location_from_scan(barcode) + if not location: + return self._response_for_scan_destination_location( + move_lines.picking_id, + move_lines, + message=self.msg_store.no_location_found(), + ) + if not self.is_dest_location_valid(move_lines.move_id, location): + return self._response_for_scan_destination_location( + move_lines.picking_id, + move_lines, + message=self.msg_store.location_not_allowed(), + ) + # Set the destination on move lines + move_lines.location_dest_id = location + # Validate the move and get back to the start + stock = self._actions_for("stock") + stock.validate_moves( + move_lines.move_id.with_context({"force_reservation": True}) + ) + return self._response_for_start( + message=self.msg_store.transfer_done_success(move_lines.picking_id) + ) + + +class ShopfloorManualProductTransferValidator(Component): + """Validators for the Manual Product Transfer endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.manual_product_transfer.validator" + _usage = "manual_product_transfer.validator" + + def scan_source_location(self): + return { + "barcode": {"required": True, "type": "string"}, + } + + def scan_product(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def confirm_quantity(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "product_id": {"coerce": to_int, "required": True, "type": "integer"}, + "quantity": {"coerce": to_float, "required": True, "type": "float"}, + "lot_id": {"coerce": to_int, "required": False, "type": "integer"}, + "barcode": {"type": "string", "required": False}, + "confirm": {"type": "boolean", "nullable": True, "required": False}, + } + + def set_quantity(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "product_id": {"coerce": to_int, "required": True, "type": "integer"}, + "quantity": {"coerce": to_float, "required": True, "type": "float"}, + "lot_id": {"coerce": to_int, "required": False, "type": "integer"}, + } + + def scan_destination_location(self): + return { + "move_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + }, + "barcode": {"required": True, "type": "string"}, + } + + +class ShopfloorManualProductTransferValidatorResponse(Component): + """Validators for the Manual Product Transfer endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.manual_product_transfer.validator.response" + _usage = "manual_product_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_product": self._schema_scan_product, + "confirm_quantity": self._schema_confirm_quantity, + "scan_destination_location": self._schema_scan_destination_location, + } + + @property + def _schema_scan_product(self): + return { + "location": self.schemas._schema_dict_of(self.schemas.location()), + } + + @property + def _schema_confirm_quantity(self): + return { + "location": self.schemas._schema_dict_of(self.schemas.location()), + "product": self.schemas._schema_dict_of(self.schemas.product()), + "lot": self.schemas._schema_dict_of(self.schemas.lot(), required=False), + "quantity": {"type": "float", "nullable": True, "required": True}, + } + + @property + def _schema_scan_destination_location(self): + return { + "picking": self.schemas._schema_dict_of(self.schemas.picking()), + "move_lines": self.schemas._schema_list_of(self.schemas.move_line()), + } + + def scan_source_location(self): + return self._response_schema(next_states={"start", "scan_product"}) + + def scan_product(self): + return self._response_schema( + next_states={ + "start", + "scan_product", + "confirm_quantity", + "scan_destination_location", + } + ) + + def confirm_quantity(self): + return self._response_schema( + next_states={"start", "confirm_quantity", "scan_destination_location"} + ) + + def set_quantity(self): + return self._response_schema(next_states={"start", "confirm_quantity"}) + + def scan_destination_location(self): + return self._response_schema(next_states={"start", "scan_destination_location"}) diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 928919d42ac..67bf2853e56 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -149,8 +149,9 @@ def start(self, barcode, confirmation=False): # restore any unreserved move/package level savepoint.rollback() return self._response_for_start(message=message) - if self.work.menu.ignore_no_putaway_available and self._no_putaway_available( - package_level + stock = self._actions_for("stock") + if self.work.menu.ignore_no_putaway_available and stock.no_putaway_available( + self.picking_types, package_level.move_line_ids ): # the putaway created a move line but no putaway was possible, so revert # to the initial state @@ -172,13 +173,6 @@ 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 a617d085463..47b135c3bee 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -49,6 +49,12 @@ from . import test_location_content_transfer_set_destination_package_or_line from . import test_location_content_transfer_putaway from . import test_location_content_transfer_mix +from . import test_manual_product_transfer_base +from . import test_manual_product_transfer_start +from . import test_manual_product_transfer_scan_product +from . import test_manual_product_transfer_confirm_quantity +from . import test_manual_product_transfer_scan_destination_location +from . import test_manual_product_transfer_misc 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_manual_product_transfer_base.py b/shopfloor/tests/test_manual_product_transfer_base.py new file mode 100644 index 00000000000..2fcfa25a287 --- /dev/null +++ b/shopfloor/tests/test_manual_product_transfer_base.py @@ -0,0 +1,115 @@ +# 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 ManualProductTransferCommonCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_manual_product_transfer") + cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.not_allowed_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Not Allowed Source Location", + "barcode": "NOT_ALLOWED_SRC_LOC", + } + ) + ) + cls.empty_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Empty Source Location", + "barcode": "EMPTY_SRC_LOC", + "location_id": cls.picking_type.default_location_src_id.id, + } + ) + ) + cls.src_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Source Location", + "barcode": "SRC_LOC", + "location_id": cls.picking_type.default_location_src_id.id, + } + ) + ) + cls.product_b.tracking = "lot" + cls.product_b_lot = cls.env["stock.production.lot"].create( + { + "name": "LOT", + "product_id": cls.product_b.id, + "company_id": cls.wh.company_id.id, + } + ) + cls._update_qty_in_location( + cls.src_location, cls.product_a, 10, + ) + cls._update_qty_in_location( + cls.src_location, cls.product_b, 10, lot=cls.product_b_lot + ) + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="manual_product_transfer") + + def assert_response_start(self, response, message=None): + self.assert_response(response, next_state="start", message=message) + + def assert_response_scan_product(self, response, location, message=None): + self.assert_response( + response, + next_state="scan_product", + data={"location": self.data.location(location)}, + message=message, + ) + + def assert_response_confirm_quantity( + self, response, location, product, quantity, lot=None, message=None + ): + data = { + "location": self.data.location(location), + "product": self.data.product(product), + "quantity": quantity, + } + if lot: + data.update(lot=self.data.lot(lot)) + self.assert_response( + response, "confirm_quantity", data=data, message=message, + ) + + def assert_response_set_quantity(self, response, move_line, message=None): + self.assert_response( + response, + "set_quantity", + data={"move_line": self.data.move_line(move_line)}, + message=message, + ) + + def assert_response_scan_destination_location( + self, response, picking, move_lines, message=None + ): + self.assert_response( + response, + "scan_destination_location", + data={ + "picking": self.data.picking(picking), + "move_lines": self.data.move_lines(move_lines), + }, + message=message, + ) diff --git a/shopfloor/tests/test_manual_product_transfer_confirm_quantity.py b/shopfloor/tests/test_manual_product_transfer_confirm_quantity.py new file mode 100644 index 00000000000..814e27ce34a --- /dev/null +++ b/shopfloor/tests/test_manual_product_transfer_confirm_quantity.py @@ -0,0 +1,271 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_manual_product_transfer_base import ManualProductTransferCommonCase + + +class ManualProductTransferConfirmQuantity(ManualProductTransferCommonCase): + """Tests for confirm_quantity state + + Endpoints: + + * /confirm_quantity + * /set_quantity + """ + + def test_confirm_quantity_wrong_product_barcode(self): + barcode = "UNKNOWN" + response = self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_a.id, + "quantity": 10, + "barcode": barcode, + }, + ) + self.assert_response_confirm_quantity( + response, + self.src_location, + self.product_a, + 10, + message=self.service.msg_store.no_product_for_barcode(barcode), + ) + + def test_confirm_quantity_wrong_lot_barcode(self): + barcode = "UNKNOWN" + response = self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_b.id, + "quantity": 10, + "lot_id": self.product_b_lot.id, + "barcode": barcode, + }, + ) + self.assert_response_confirm_quantity( + response, + self.src_location, + self.product_b, + 10, + self.product_b_lot, + message=self.service.msg_store.no_lot_for_barcode(barcode), + ) + + def test_confirm_quantity_product_barcode_ok(self): + barcode = self.product_a.barcode + response = self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_a.id, + "quantity": 10, + "barcode": barcode, + }, + ) + move_lines = self.service._find_user_move_lines( + self.src_location, self.product_a + ) + self.assert_response_scan_destination_location( + response, move_lines.picking_id, move_lines + ) + + def test_confirm_quantity_product_confirm_ok(self): + response = self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_a.id, + "quantity": 10, + "confirm": True, + }, + ) + move_lines = self.service._find_user_move_lines( + self.src_location, self.product_a + ) + self.assert_response_scan_destination_location( + response, move_lines.picking_id, move_lines + ) + + def test_confirm_quantity_lot_barcode_ok(self): + barcode = self.product_b_lot.name + response = self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_b.id, + "quantity": 10, + "lot_id": self.product_b_lot.id, + "barcode": barcode, + }, + ) + move_lines = self.service._find_user_move_lines( + self.src_location, self.product_b, self.product_b_lot + ) + self.assertEqual(move_lines.lot_id, self.product_b_lot) + self.assert_response_scan_destination_location( + response, move_lines.picking_id, move_lines + ) + + def test_confirm_quantity_lot_confirm_ok(self): + response = self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_b.id, + "quantity": 10, + "lot_id": self.product_b_lot.id, + "confirm": True, + }, + ) + move_lines = self.service._find_user_move_lines( + self.src_location, self.product_b, self.product_b_lot + ) + self.assertEqual(move_lines.lot_id, self.product_b_lot) + self.assert_response_scan_destination_location( + response, move_lines.picking_id, move_lines + ) + + def test_confirm_quantity_with_unreservation_disabled(self): + self.menu.sudo().allow_unreserve_other_moves = False + # initial qty is 10, but we reserve 2 qties (so 8 fully free) + picking = self._create_picking( + picking_type=self.env.ref("stock.picking_type_out"), + lines=[(self.product_a, 2)], + confirm=True, + ) + picking.action_assign() + # confirm 9 qties to process (more than 8) + response = self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_a.id, + "quantity": 9, + "confirm": True, + }, + ) + # we get an error message and the quantity to move is reset to 8 + self.assert_response_confirm_quantity( + response, + self.src_location, + self.product_a, + 8, + message=self.service.msg_store.qty_exceeds_initial_qty(), + ) + move_lines = self.service._find_user_move_lines( + self.src_location, self.product_a + ) + self.assertFalse(move_lines) + + def test_confirm_quantity_with_unreservation_enabled(self): + self.menu.sudo().allow_unreserve_other_moves = True + # initial qty is 10, but we reserve 2 qties (so 8 fully free) + picking = self._create_picking( + picking_type=self.env.ref("stock.picking_type_out"), + lines=[(self.product_a, 2)], + confirm=True, + ) + picking.action_assign() + # confirm 9 qties to process (more than 8) + response = self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_a.id, + "quantity": 9, + "confirm": True, + }, + ) + # the existing move lines has been unreserve to satisfy the move of 9 + # qties to process + self.assertRecordValues( + picking.move_lines, + [ + { + "state": "partially_available", + "product_uom_qty": 2, + "reserved_availability": 1, + }, + ], + ) + move_lines = self.service._find_user_move_lines( + self.src_location, self.product_a + ) + self.assertRecordValues( + move_lines, [{"state": "assigned", "product_uom_qty": 9, "qty_done": 9}] + ) + self.assert_response_scan_destination_location( + response, move_lines.picking_id, move_lines + ) + + def test_confirm_quantity_exceeds_initial_qty(self): + # initial qty is 10, but we try to process 11 + response = self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_a.id, + "quantity": 11, + "confirm": True, + }, + ) + self.assert_response_confirm_quantity( + response, + self.src_location, + self.product_a, + 10, + message=self.service.msg_store.qty_exceeds_initial_qty(), + ) + + def test_confirm_quantity_with_putaway_destination_required_error(self): + self.menu.sudo().ignore_no_putaway_available = True + response = self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_a.id, + "quantity": 10, + "confirm": True, + }, + ) + self.assert_response_confirm_quantity( + response, + self.src_location, + self.product_a, + 10, + message=self.service.msg_store.no_putaway_destination_available(), + ) + + def test_set_quantity_ok(self): + # initial qty is 10 + response = self.service.dispatch( + "set_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_a.id, + "quantity": 9, + }, + ) + self.assert_response_confirm_quantity( + response, self.src_location, self.product_a, 9, + ) + + def test_set_quantity_exceeds_initial_qty(self): + # initial qty is 10 + response = self.service.dispatch( + "set_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_a.id, + "quantity": 11, + }, + ) + self.assert_response_confirm_quantity( + response, + self.src_location, + self.product_a, + 10, + message=self.service.msg_store.qty_exceeds_initial_qty(), + ) diff --git a/shopfloor/tests/test_manual_product_transfer_misc.py b/shopfloor/tests/test_manual_product_transfer_misc.py new file mode 100644 index 00000000000..b1ac5032b44 --- /dev/null +++ b/shopfloor/tests/test_manual_product_transfer_misc.py @@ -0,0 +1,47 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_manual_product_transfer_base import ManualProductTransferCommonCase + + +class ManualProductTransferMisc(ManualProductTransferCommonCase): + """Test helper methods used in the manual product transfer scenario.""" + + def test_get_product_qty(self): + # 1. simple case + qty = self.service._get_product_qty(self.src_location, self.product_a) + self.assertEqual(qty, 10) + # 2. with some qties reserved: returns the qty available without reservation + picking = self._create_picking(lines=[(self.product_a, 4)], confirm=True) + picking.action_assign() + qty = self.service._get_product_qty( + self.src_location, self.product_a, free=True + ) + self.assertEqual(qty, 6) + + def test_get_product_qty_processed(self): + # No qty processed at first + qty = self.service._get_product_qty_processed(self.src_location, self.product_a) + self.assertEqual(qty, 0) + # Process some qties (without validation) and check the qty processed + picking = self._create_picking(lines=[(self.product_a, 10)], confirm=True) + picking.action_assign() + picking.move_line_ids.qty_done = 8 + qty = self.service._get_product_qty_processed(self.src_location, self.product_a) + self.assertEqual(qty, 8) + + def test_get_initial_qty(self): + # 1. simple case + qty = self.service._get_initial_qty(self.src_location, self.product_a) + self.assertEqual(qty, 10) + # 2. with some qties reserved and "Allow to process reserved quantities" + # option disabled: returns the qty available without reservation + picking = self._create_picking(lines=[(self.product_a, 4)], confirm=True) + picking.action_assign() + qty = self.service._get_initial_qty(self.src_location, self.product_a) + self.assertEqual(qty, 6) + # 3. with some qties reserved and "Allow to process reserved quantities" + # option enabled: returns the qty available reservation included + self.menu.sudo().allow_unreserve_other_moves = True + qty = self.service._get_initial_qty(self.src_location, self.product_a) + self.assertEqual(qty, 10) diff --git a/shopfloor/tests/test_manual_product_transfer_scan_destination_location.py b/shopfloor/tests/test_manual_product_transfer_scan_destination_location.py new file mode 100644 index 00000000000..6140f592a94 --- /dev/null +++ b/shopfloor/tests/test_manual_product_transfer_scan_destination_location.py @@ -0,0 +1,66 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_manual_product_transfer_base import ManualProductTransferCommonCase + + +class ManualProductTransferScanDestinationLocation(ManualProductTransferCommonCase): + """Tests for confirm_quantity state + + Endpoints: + + * /scan_destination_location + """ + + def test_scan_destination_location_wrong_picking_id(self): + response = self.service.dispatch( + "scan_destination_location", + params={"move_line_ids": [-1], "barcode": self.shelf1.barcode}, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found(), + ) + + def _confirm_quantity(self): + self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_a.id, + "quantity": 10, + "confirm": True, + }, + ) + move_lines = self.service._find_user_move_lines( + self.src_location, self.product_a + ) + picking = move_lines.picking_id + return picking, move_lines + + def test_scan_destination_location_wrong_destination(self): + picking, move_lines = self._confirm_quantity() + response = self.service.dispatch( + "scan_destination_location", + params={ + "move_line_ids": move_lines.ids, + "barcode": self.not_allowed_location.barcode, + }, + ) + self.assertEqual(move_lines.state, "assigned") + self.assert_response_scan_destination_location( + response, + picking, + move_lines, + message=self.service.msg_store.location_not_allowed(), + ) + + def test_scan_destination_location_ok(self): + picking, move_lines = self._confirm_quantity() + response = self.service.dispatch( + "scan_destination_location", + params={"move_line_ids": move_lines.ids, "barcode": self.shelf1.barcode}, + ) + self.assertEqual(move_lines.state, "done") + self.assert_response_start( + response, message=self.service.msg_store.transfer_done_success(picking) + ) diff --git a/shopfloor/tests/test_manual_product_transfer_scan_product.py b/shopfloor/tests/test_manual_product_transfer_scan_product.py new file mode 100644 index 00000000000..55760722d91 --- /dev/null +++ b/shopfloor/tests/test_manual_product_transfer_scan_product.py @@ -0,0 +1,92 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_manual_product_transfer_base import ManualProductTransferCommonCase + + +class ManualProductTransferScanProduct(ManualProductTransferCommonCase): + """Tests for scan_product state + + Endpoints: + + * /scan_product + """ + + def test_scan_product_not_found_error(self): + response = self.service.dispatch( + "scan_product", + params={"location_id": self.src_location.id, "barcode": "UNKNOWN"}, + ) + self.assert_response_scan_product( + response, + self.src_location, + message=self.service.msg_store.barcode_not_found(), + ) + + def test_scan_product_tracked_by_lot_error(self): + response = self.service.dispatch( + "scan_product", + params={ + "location_id": self.src_location.id, + "barcode": self.product_b.barcode, + }, + ) + self.assert_response_scan_product( + response, + self.src_location, + message=self.service.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + + def test_scan_product_product_ok(self): + response = self.service.dispatch( + "scan_product", + params={ + "location_id": self.src_location.id, + "barcode": self.product_a.barcode, + }, + ) + self.assert_response_confirm_quantity( + response, self.src_location, self.product_a, 10, + ) + + def test_scan_product_lot_ok(self): + response = self.service.dispatch( + "scan_product", + params={ + "location_id": self.src_location.id, + "barcode": self.product_b_lot.name, + }, + ) + self.assert_response_confirm_quantity( + response, self.src_location, self.product_b, 10, self.product_b_lot, + ) + + def test_scan_product_recover_session(self): + # create a move and its move lines by confirming qty + self.service.dispatch( + "confirm_quantity", + params={ + "location_id": self.src_location.id, + "product_id": self.product_a.id, + "quantity": 10, + "confirm": True, + }, + ) + move_lines = self.service._find_user_move_lines( + self.src_location, self.product_a + ) + # check we are redirected to the "scan_destination_location" state + # when scanning the same product + response = self.service.dispatch( + "scan_product", + params={ + "location_id": self.src_location.id, + "barcode": self.product_a.barcode, + }, + ) + self.assert_response_scan_destination_location( + response, + move_lines.picking_id, + move_lines, + message=self.service.msg_store.recovered_previous_session(), + ) diff --git a/shopfloor/tests/test_manual_product_transfer_start.py b/shopfloor/tests/test_manual_product_transfer_start.py new file mode 100644 index 00000000000..5f80f300c2d --- /dev/null +++ b/shopfloor/tests/test_manual_product_transfer_start.py @@ -0,0 +1,44 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_manual_product_transfer_base import ManualProductTransferCommonCase + + +class ManualProductTransferStart(ManualProductTransferCommonCase): + """Tests for start state + + Endpoints: + + * /scan_source_location + """ + + def test_scan_source_location_not_found(self): + response = self.service.dispatch( + "scan_source_location", params={"barcode": "UNKNOWN"} + ) + self.assert_response_start( + response, message=self.service.msg_store.no_location_found() + ) + + def test_scan_source_location_not_allowed(self): + response = self.service.dispatch( + "scan_source_location", + params={"barcode": self.not_allowed_location.barcode}, + ) + self.assert_response_start( + response, message=self.service.msg_store.location_not_allowed() + ) + + def test_scan_source_location_empty(self): + response = self.service.dispatch( + "scan_source_location", params={"barcode": self.empty_location.barcode} + ) + self.assert_response_start( + response, message=self.service.msg_store.location_empty(self.empty_location) + ) + + def test_scan_source_location(self): + response = self.service.dispatch( + "scan_source_location", params={"barcode": self.src_location.barcode} + ) + self.assert_response_scan_product(response, self.src_location) From 0f59753b6a07c33106c6d82dbb4b4f54792fe4ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 9 Dec 2021 17:25:10 +0100 Subject: [PATCH 635/710] shopfloor: move 'Manual product transfer' in its own addon --- shopfloor/__manifest__.py | 2 - shopfloor/actions/message.py | 6 - shopfloor/data/shopfloor_scenario_data.xml | 10 - shopfloor/demo/shopfloor_menu_demo.xml | 9 - shopfloor/demo/stock_picking_type_demo.xml | 15 - .../manual_product_transfer_diag_seq.plantuml | 41 -- .../docs/manual_product_transfer_diag_seq.png | Bin 58262 -> 0 bytes .../migrations/14.0.1.0.0/pre-migration.py | 14 + shopfloor/services/__init__.py | 1 - shopfloor/services/manual_product_transfer.py | 662 ------------------ shopfloor/tests/__init__.py | 6 - .../test_manual_product_transfer_base.py | 115 --- ...anual_product_transfer_confirm_quantity.py | 271 ------- .../test_manual_product_transfer_misc.py | 47 -- ...duct_transfer_scan_destination_location.py | 66 -- ...st_manual_product_transfer_scan_product.py | 92 --- .../test_manual_product_transfer_start.py | 44 -- 17 files changed, 14 insertions(+), 1387 deletions(-) delete mode 100644 shopfloor/docs/manual_product_transfer_diag_seq.plantuml delete mode 100644 shopfloor/docs/manual_product_transfer_diag_seq.png create mode 100644 shopfloor/migrations/14.0.1.0.0/pre-migration.py delete mode 100644 shopfloor/services/manual_product_transfer.py delete mode 100644 shopfloor/tests/test_manual_product_transfer_base.py delete mode 100644 shopfloor/tests/test_manual_product_transfer_confirm_quantity.py delete mode 100644 shopfloor/tests/test_manual_product_transfer_misc.py delete mode 100644 shopfloor/tests/test_manual_product_transfer_scan_destination_location.py delete mode 100644 shopfloor/tests/test_manual_product_transfer_scan_product.py delete mode 100644 shopfloor/tests/test_manual_product_transfer_start.py diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 16281cf112c..059be997e9c 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -39,8 +39,6 @@ "stock_storage_type", # TODO: used for picking.carrier_id detail info # and to validate packaging/carrier in checkout scenario - # OCA / stock-logistics-workflow - "stock_restrict_lot", # This must be an optional dep "delivery", # OCA / product-attribute diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index d2a28d2744f..0d6793c4964 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -575,9 +575,3 @@ def picking_without_carrier_cannot_pack(self, picking): "The system couldn't pack goods automatically." ).format(picking), } - - def qty_exceeds_initial_qty(self): - return { - "message_type": "error", - "body": _("This quantity exceeds the initial one."), - } diff --git a/shopfloor/data/shopfloor_scenario_data.xml b/shopfloor/data/shopfloor_scenario_data.xml index 3b6ad81e338..a4128371a07 100644 --- a/shopfloor/data/shopfloor_scenario_data.xml +++ b/shopfloor/data/shopfloor_scenario_data.xml @@ -41,16 +41,6 @@ "allow_create_moves": true, "allow_unreserve_other_moves": true, "allow_ignore_no_putaway_available": true -} - - - - Manual product transfer - manual_product_transfer - -{ - "allow_unreserve_other_moves": true, - "allow_ignore_no_putaway_available": true } diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index dc57c1ba2e4..5360865ca5f 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -55,13 +55,4 @@ eval="[(4, ref('shopfloor.picking_type_location_content_transfer_demo'))]" /> - - Manual Product Transfer - 70 - - - diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index b76b3db1c6e..c912a1087d5 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -90,19 +90,4 @@ - - Manual Product Transfer - MPT - - - - - - - - - internal - - - diff --git a/shopfloor/docs/manual_product_transfer_diag_seq.plantuml b/shopfloor/docs/manual_product_transfer_diag_seq.plantuml deleted file mode 100644 index 041779ecf7b..00000000000 --- a/shopfloor/docs/manual_product_transfer_diag_seq.plantuml +++ /dev/null @@ -1,41 +0,0 @@ -# Diagram to generate with PlantUML (https://plantuml.com/) -# -# $ sudo apt install plantuml -# $ plantuml delivery_diag_seq.plantuml -# - -@startuml - -skinparam roundcorner 20 -skinparam sequence { - -ParticipantBorderColor #875A7B -ParticipantBackgroundColor #875A7B -ParticipantFontSize 17 -ParticipantFontColor white - -LifeLineBorderColor #875A7B - -ArrowColor #00A09D -} - -header -title Manual Product Transfer scenario - -== /scan_source_location == -start -> scan_product: **/scan_source_location**(barcode) - -== /scan_product == -scan_product -> confirm_quantity: **/scan_product**(location_id, barcode) -scan_product -> scan_destination_location: **/scan_product**(location_id, barcode) \n(session recovering if there were existing move lines linked to the current operator for this source location and product) - -== /set_quantity (optional) == -confirm_quantity -> confirm_quantity: **/set_quantity**(location_id, product_id, quantity, lot_id=None) - -== /confirm_quantity == -confirm_quantity -> scan_destination_location: **/confirm_quantity**(location_id, product_id, quantity, lot_id=None, barcode=None, confirm=False) \n(create the move and assign it) - -== /scan_destination_location == -scan_destination_location -> start: **/scan_destination_location**(move_id, barcode) \n(post the move) - -@enduml diff --git a/shopfloor/docs/manual_product_transfer_diag_seq.png b/shopfloor/docs/manual_product_transfer_diag_seq.png deleted file mode 100644 index 3370390dcfdcde4e03dc59bdce5c05af57640da3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58262 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", 0)] - ) - if not quants: - return self._response_for_start( - message=self.msg_store.location_empty(location) - ) - return self._response_for_scan_product(location) - - def _find_user_move_lines_domain(self, location, product, lot=None): - domain = [ - ("location_id", "=", location.id), - # ("qty_done", ">", 0), - ("state", "not in", ("cancel", "done")), - ("shopfloor_user_id", "=", self.env.user.id), - ("picking_id.picking_type_id", "in", self.picking_types.ids), - ("product_id", "=", product.id), - ] - if lot: - domain.append(("lot_id", "=", lot.id)) - return domain - - def _find_user_move_lines(self, location, product, lot=None): - """Find move lines processed by the current user.""" - domain = self._find_user_move_lines_domain(location, product, lot) - return self.env["stock.move.line"].search(domain) - - def _find_location_move_lines_domain( - self, location, product, lot=None, with_qty_done=False - ): - domain = [ - ("location_id", "=", location.id), - ("product_id", "=", product.id), - ("state", "in", ("assigned", "partially_available")), - ("shopfloor_user_id", "=", False), - ] - if lot: - domain.append(("lot_id", "=", lot.id)) - if with_qty_done: - domain.append(("qty_done", ">", 0)) - else: - domain.append(("qty_done", "=", 0)) - return domain - - def _find_location_move_lines( - self, location, product, lot=None, with_qty_done=False - ): - """Find existing move lines in progress related to the source location - but not linked to any user. - """ - return self.env["stock.move.line"].search( - self._find_location_move_lines_domain(location, product, lot, with_qty_done) - ) - - def _get_product_qty(self, location, product, lot=None, free=False): - """Returns the available quantity for a given location/product/lot. - - The available quantity returned depends on the - "Allow to process reserved quantities" option, if this one is enabled - then the method will return the quantity on hands, not taking into - account the reserved quantities (to be able to unreserve them if needed). - Otherwise the method will return the actual free quantity. - """ - product = product.with_context( - location=location.id, lot_id=lot.id if lot else None - ) - if free: - return product.free_qty - return product.qty_available - - def _get_product_qty_processed(self, location, product, lot=None): - """Returns the current quantity processed (qty done) by users for the - given location/product/lot among reserved quantities (existing move lines). - """ - move_lines = self._find_location_move_lines( - location, product, lot, with_qty_done=True - ) - return sum( - [ - line.product_id.uom_id._compute_quantity( - line.qty_done, line.product_uom_id, rounding_method="HALF-UP", - ) - for line in move_lines - ] - ) - - def _get_initial_qty(self, location, product, lot=None): - """Compute the initial quantity for the given location/product/lot.""" - if self.work.menu.allow_unreserve_other_moves: - # If the "Allow to process reserved quantities" is enabled, the - # initial qty is the available qty (reservation included) from which - # we substract the qty currently processed (qty_done on move lines) - current_qty = self._get_product_qty(location, product, lot, free=False) - done_qty = self._get_product_qty_processed(location, product, lot) - else: - # Otherwise we simply use the free qty (without any reservation) - # available in the location as the initial qty - current_qty = self._get_product_qty(location, product, lot, free=True) - done_qty = 0 - return current_qty - done_qty - - def scan_product(self, location_id, barcode): - """Scan a product or a lot existing in the source location. - - If there is already some work in progress for the scanned product or lot, - restore it. - - Transitions: - * confirm_quantity: product or lot was found in the source location - * scan_product: scanned product or lot is wrong (error) - """ - location = self.env["stock.location"].browse(location_id).exists() - if not location: - return self._response_for_start(message=self.msg_store.record_not_found()) - search = self._actions_for("search") - initial_qty = None - # Search by product first - product = search.product_from_scan(barcode) - if product: - if product.tracking != "none": - return self._response_for_scan_product( - location, - message=self.msg_store.scan_lot_on_product_tracked_by_lot(), - ) - existing_move_lines = self._find_user_move_lines(location, product) - picking = first(existing_move_lines.picking_id) - if existing_move_lines: - return self._response_for_scan_destination_location( - picking, - picking.move_line_ids & existing_move_lines, - message=self.msg_store.recovered_previous_session(), - ) - # Search by lot - lot = search.lot_from_scan(barcode) - if lot: - product = lot.product_id - existing_move_lines = self._find_user_move_lines( - location, lot.product_id, lot=lot - ) - picking = first(existing_move_lines.picking_id) - if existing_move_lines: - return self._response_for_scan_destination_location( - picking, - picking.move_line_ids & existing_move_lines, - message=self.msg_store.recovered_previous_session(), - ) - # Compute the initial quantity - initial_qty = self._get_initial_qty(location, product, lot) - # No product available quantity to move - if ( - product - and lot - and float_is_zero(initial_qty, precision_rounding=product.uom_id.rounding) - ): - return self._response_for_scan_product( - location, message=self.msg_store.no_product_in_location(location) - ) - # Available quantity to move - if initial_qty: - return self._response_for_confirm_quantity( - location, product, initial_qty, lot - ) - # No product or lot found - return self._response_for_scan_product( - location, message=self.msg_store.barcode_not_found() - ) - - def _check_quantity(self, location, product, lot, quantity): - """Check that the input quantity does not exceeds the initial quantity. - - Returns a response with an error message if applicable. - """ - initial_qty = self._get_initial_qty(location, product, lot) - if ( - float_compare( - quantity, initial_qty, precision_rounding=product.uom_id.rounding - ) - == 1 - ): - return self._response_for_confirm_quantity( - location, - product, - initial_qty, - lot, - message=self.msg_store.qty_exceeds_initial_qty(), - ) - - def set_quantity(self, location_id, product_id, quantity, lot_id=None): - """Allows to change the initial quantity to move. - - Transitions: - * confirm_quantity: the move is updated with the new quantity - * confirm_quantity + error message: the new quantity exceeds the initial qty - """ - location = self.env["stock.location"].browse(location_id).exists() - product = self.env["product.product"].browse(product_id).exists() - lot = None - if lot_id: - lot = self.env["stock.production.lot"].browse(lot_id).exists() - # Get back on the start screen if record IDs do not exist - if not location or not product or (not lot if lot_id else False): - return self._response_for_start(message=self.msg_store.record_not_found()) - response = self._check_quantity(location, product, lot, quantity) - if response: - return response - return self._response_for_confirm_quantity(location, product, quantity, lot) - - def _create_move_from_location(self, location, product, quantity, lot=None): - picking_type = self.picking_types - move_vals = { - "name": product.name, - "company_id": picking_type.company_id.id, - "product_id": product.id, - "product_uom": product.uom_id.id, - "product_uom_qty": quantity, - "location_id": location.id, - "location_dest_id": picking_type.default_location_dest_id.id, - "origin": self.work.menu.name, - "picking_type_id": picking_type.id, - } - if lot: - move_vals["restrict_lot_id"] = lot.id - move = self.env["stock.move"].create(move_vals) - move._action_confirm(merge=False) - move.with_context({"force_reservation": True})._action_assign() - assert move.state == "assigned", "The reservation of quantities has failed" - move.move_line_ids.shopfloor_user_id = self.env.user - for line in move.move_line_ids: - line.qty_done = line.product_uom_qty - return move - - def confirm_quantity( - self, - location_id, - product_id, - quantity, - lot_id=None, - barcode=None, - confirm=False, - ): - """Confirm the quantity to move by scanning the product/lot a second - time (`barcode`) or by clicking the button (`confirm`). - - Transitions: - * scan_destination_location: quantity is confirmed for the current product/lot - * confirm_quantity: scanned product or lot is wrong (error) - """ - location = self.env["stock.location"].browse(location_id).exists() - product = self.env["product.product"].browse(product_id).exists() - lot = None - if lot_id: - lot = self.env["stock.production.lot"].browse(lot_id).exists() - # Get back on the start screen if record IDs do not exist - if not location or not product or (not lot if lot_id else False): - return self._response_for_start(message=self.msg_store.record_not_found()) - # Check input barcode - if barcode: - response = self._confirm_quantity_check_barcode( - location, product, quantity, lot, barcode - ) - if response: - return response - confirm = True - # If not confirmed, get back on the same screen (should not happen, this - # means no barcode is scanned or no confirm button is clicked) - if not confirm: - return self._response_for_confirm_quantity( - location, product, quantity, lot, - ) - # Check the input quantity - initial_qty = self._get_initial_qty(location, product, lot) - response = self._check_quantity(location, product, lot, quantity) - if response: - return response - savepoint = self._actions_for("savepoint").new() - stock = self._actions_for("stock") - # Quantity has been confirmed, try to create the move - # 1. Check there is enough stock in the location to move, otherwise - # unreserve existing moves if applicable - current_qty = self._get_product_qty(location, product, lot, free=True) - initial_qty = self._get_initial_qty(location, product, lot) - unreserved_moves = self.env["stock.move"].browse() - if self.work.menu.allow_unreserve_other_moves and ( - # FIXME use float_compare - current_qty - < quantity - <= initial_qty - ): - # If available qty (qty non reserved) < quantity set => Unreserve - # other moves for this product and location - move_lines = self._find_location_move_lines(location, product, lot) - move_lines, unreserved_moves, response = self._unreserve_other_lines( - location, move_lines - ) - if response: - savepoint.rollback() - return response - # 2. Create the move and assign it - move = self._create_move_from_location(location, product, quantity, lot) - # 3. If the "Ignore transfers when no put-away is available" is enabled - # and no putaway has been computed, rollback the creation of the move - if self.work.menu.ignore_no_putaway_available and stock.no_putaway_available( - self.picking_types, move.move_line_ids - ): - savepoint.rollback() - return self._response_for_confirm_quantity( - location, - product, - quantity, - lot, - message=self.msg_store.no_putaway_destination_available(), - ) - # 4. If moves were unreserved -> Reserve them back again - unreserved_moves.with_context({"force_reservation": True})._action_assign() - savepoint.release() - return self._response_for_scan_destination_location( - move.picking_id, move.move_line_ids - ) - - def _confirm_quantity_check_barcode( - self, location, product, quantity, lot, barcode - ): - search = self._actions_for("search") - # Check if the lot matches if any - if lot: - scanned_lot = search.lot_from_scan(barcode) - # Barcode is not a lot - if not scanned_lot: - return self._response_for_confirm_quantity( - location, - product, - quantity, - lot, - message=self.msg_store.no_lot_for_barcode(barcode), - ) - # Barcode is a lot but doesn't match the processed one - if lot != scanned_lot: - return self._response_for_confirm_quantity( - location, product, quantity, lot, message=self.msg_store.wrong_lot() - ) - return - # Search by product - scanned_product = search.product_from_scan(barcode) - # Barcode is not a product - if not scanned_product: - return self._response_for_confirm_quantity( - location, - product, - quantity, - lot, - message=self.msg_store.no_product_for_barcode(barcode), - ) - # Barcode is a product but doesn't match the processed one - if product != scanned_product: - return self._response_for_confirm_quantity( - location, product, quantity, lot, message=self.msg_store.wrong_product() - ) - - # FIXME copy pasted from location content transfer, put it elsewhere? - 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, self.env["stock.move"].browse(), None) - unreserved_moves = move_lines.move_id - location_move_lines = self.env["stock.move.line"].search( - [ - ("location_id", "=", location.id), - ("state", "in", ("assigned", "partially_available")), - ] - ) - 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_destination_location(self, move_line_ids, barcode): - """Scan the destination location and post the move. - - Transitions: - * start: move has been posted successfully - * scan_destination_location: scanned location is wrong (error) - """ - move_lines = self.env["stock.move.line"].browse(move_line_ids).exists() - # Get back on the start screen if record IDs do not exist - if not move_lines or move_lines.ids != move_line_ids: - return self._response_for_start(message=self.msg_store.record_not_found()) - search = self._actions_for("search") - # Check the scanned destination - location = search.location_from_scan(barcode) - if not location: - return self._response_for_scan_destination_location( - move_lines.picking_id, - move_lines, - message=self.msg_store.no_location_found(), - ) - if not self.is_dest_location_valid(move_lines.move_id, location): - return self._response_for_scan_destination_location( - move_lines.picking_id, - move_lines, - message=self.msg_store.location_not_allowed(), - ) - # Set the destination on move lines - move_lines.location_dest_id = location - # Validate the move and get back to the start - stock = self._actions_for("stock") - stock.validate_moves( - move_lines.move_id.with_context({"force_reservation": True}) - ) - return self._response_for_start( - message=self.msg_store.transfer_done_success(move_lines.picking_id) - ) - - -class ShopfloorManualProductTransferValidator(Component): - """Validators for the Manual Product Transfer endpoints""" - - _inherit = "base.shopfloor.validator" - _name = "shopfloor.manual_product_transfer.validator" - _usage = "manual_product_transfer.validator" - - def scan_source_location(self): - return { - "barcode": {"required": True, "type": "string"}, - } - - def scan_product(self): - return { - "location_id": {"coerce": to_int, "required": True, "type": "integer"}, - "barcode": {"required": True, "type": "string"}, - } - - def confirm_quantity(self): - return { - "location_id": {"coerce": to_int, "required": True, "type": "integer"}, - "product_id": {"coerce": to_int, "required": True, "type": "integer"}, - "quantity": {"coerce": to_float, "required": True, "type": "float"}, - "lot_id": {"coerce": to_int, "required": False, "type": "integer"}, - "barcode": {"type": "string", "required": False}, - "confirm": {"type": "boolean", "nullable": True, "required": False}, - } - - def set_quantity(self): - return { - "location_id": {"coerce": to_int, "required": True, "type": "integer"}, - "product_id": {"coerce": to_int, "required": True, "type": "integer"}, - "quantity": {"coerce": to_float, "required": True, "type": "float"}, - "lot_id": {"coerce": to_int, "required": False, "type": "integer"}, - } - - def scan_destination_location(self): - return { - "move_line_ids": { - "type": "list", - "required": True, - "schema": {"coerce": to_int, "required": True, "type": "integer"}, - }, - "barcode": {"required": True, "type": "string"}, - } - - -class ShopfloorManualProductTransferValidatorResponse(Component): - """Validators for the Manual Product Transfer endpoints responses""" - - _inherit = "base.shopfloor.validator.response" - _name = "shopfloor.manual_product_transfer.validator.response" - _usage = "manual_product_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_product": self._schema_scan_product, - "confirm_quantity": self._schema_confirm_quantity, - "scan_destination_location": self._schema_scan_destination_location, - } - - @property - def _schema_scan_product(self): - return { - "location": self.schemas._schema_dict_of(self.schemas.location()), - } - - @property - def _schema_confirm_quantity(self): - return { - "location": self.schemas._schema_dict_of(self.schemas.location()), - "product": self.schemas._schema_dict_of(self.schemas.product()), - "lot": self.schemas._schema_dict_of(self.schemas.lot(), required=False), - "quantity": {"type": "float", "nullable": True, "required": True}, - } - - @property - def _schema_scan_destination_location(self): - return { - "picking": self.schemas._schema_dict_of(self.schemas.picking()), - "move_lines": self.schemas._schema_list_of(self.schemas.move_line()), - } - - def scan_source_location(self): - return self._response_schema(next_states={"start", "scan_product"}) - - def scan_product(self): - return self._response_schema( - next_states={ - "start", - "scan_product", - "confirm_quantity", - "scan_destination_location", - } - ) - - def confirm_quantity(self): - return self._response_schema( - next_states={"start", "confirm_quantity", "scan_destination_location"} - ) - - def set_quantity(self): - return self._response_schema(next_states={"start", "confirm_quantity"}) - - def scan_destination_location(self): - return self._response_schema(next_states={"start", "scan_destination_location"}) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 47b135c3bee..a617d085463 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -49,12 +49,6 @@ from . import test_location_content_transfer_set_destination_package_or_line from . import test_location_content_transfer_putaway from . import test_location_content_transfer_mix -from . import test_manual_product_transfer_base -from . import test_manual_product_transfer_start -from . import test_manual_product_transfer_scan_product -from . import test_manual_product_transfer_confirm_quantity -from . import test_manual_product_transfer_scan_destination_location -from . import test_manual_product_transfer_misc 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_manual_product_transfer_base.py b/shopfloor/tests/test_manual_product_transfer_base.py deleted file mode 100644 index 2fcfa25a287..00000000000 --- a/shopfloor/tests/test_manual_product_transfer_base.py +++ /dev/null @@ -1,115 +0,0 @@ -# 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 ManualProductTransferCommonCase(CommonCase): - @classmethod - def setUpClassVars(cls, *args, **kwargs): - super().setUpClassVars(*args, **kwargs) - cls.menu = cls.env.ref("shopfloor.shopfloor_menu_manual_product_transfer") - cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") - cls.picking_type = cls.menu.picking_type_ids - cls.wh = cls.picking_type.warehouse_id - - @classmethod - def setUpClassBaseData(cls, *args, **kwargs): - super().setUpClassBaseData(*args, **kwargs) - cls.not_allowed_location = ( - cls.env["stock.location"] - .sudo() - .create( - { - "name": "Not Allowed Source Location", - "barcode": "NOT_ALLOWED_SRC_LOC", - } - ) - ) - cls.empty_location = ( - cls.env["stock.location"] - .sudo() - .create( - { - "name": "Empty Source Location", - "barcode": "EMPTY_SRC_LOC", - "location_id": cls.picking_type.default_location_src_id.id, - } - ) - ) - cls.src_location = ( - cls.env["stock.location"] - .sudo() - .create( - { - "name": "Source Location", - "barcode": "SRC_LOC", - "location_id": cls.picking_type.default_location_src_id.id, - } - ) - ) - cls.product_b.tracking = "lot" - cls.product_b_lot = cls.env["stock.production.lot"].create( - { - "name": "LOT", - "product_id": cls.product_b.id, - "company_id": cls.wh.company_id.id, - } - ) - cls._update_qty_in_location( - cls.src_location, cls.product_a, 10, - ) - cls._update_qty_in_location( - cls.src_location, cls.product_b, 10, lot=cls.product_b_lot - ) - - def setUp(self): - super().setUp() - with self.work_on_services(menu=self.menu, profile=self.profile) as work: - self.service = work.component(usage="manual_product_transfer") - - def assert_response_start(self, response, message=None): - self.assert_response(response, next_state="start", message=message) - - def assert_response_scan_product(self, response, location, message=None): - self.assert_response( - response, - next_state="scan_product", - data={"location": self.data.location(location)}, - message=message, - ) - - def assert_response_confirm_quantity( - self, response, location, product, quantity, lot=None, message=None - ): - data = { - "location": self.data.location(location), - "product": self.data.product(product), - "quantity": quantity, - } - if lot: - data.update(lot=self.data.lot(lot)) - self.assert_response( - response, "confirm_quantity", data=data, message=message, - ) - - def assert_response_set_quantity(self, response, move_line, message=None): - self.assert_response( - response, - "set_quantity", - data={"move_line": self.data.move_line(move_line)}, - message=message, - ) - - def assert_response_scan_destination_location( - self, response, picking, move_lines, message=None - ): - self.assert_response( - response, - "scan_destination_location", - data={ - "picking": self.data.picking(picking), - "move_lines": self.data.move_lines(move_lines), - }, - message=message, - ) diff --git a/shopfloor/tests/test_manual_product_transfer_confirm_quantity.py b/shopfloor/tests/test_manual_product_transfer_confirm_quantity.py deleted file mode 100644 index 814e27ce34a..00000000000 --- a/shopfloor/tests/test_manual_product_transfer_confirm_quantity.py +++ /dev/null @@ -1,271 +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_manual_product_transfer_base import ManualProductTransferCommonCase - - -class ManualProductTransferConfirmQuantity(ManualProductTransferCommonCase): - """Tests for confirm_quantity state - - Endpoints: - - * /confirm_quantity - * /set_quantity - """ - - def test_confirm_quantity_wrong_product_barcode(self): - barcode = "UNKNOWN" - response = self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_a.id, - "quantity": 10, - "barcode": barcode, - }, - ) - self.assert_response_confirm_quantity( - response, - self.src_location, - self.product_a, - 10, - message=self.service.msg_store.no_product_for_barcode(barcode), - ) - - def test_confirm_quantity_wrong_lot_barcode(self): - barcode = "UNKNOWN" - response = self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_b.id, - "quantity": 10, - "lot_id": self.product_b_lot.id, - "barcode": barcode, - }, - ) - self.assert_response_confirm_quantity( - response, - self.src_location, - self.product_b, - 10, - self.product_b_lot, - message=self.service.msg_store.no_lot_for_barcode(barcode), - ) - - def test_confirm_quantity_product_barcode_ok(self): - barcode = self.product_a.barcode - response = self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_a.id, - "quantity": 10, - "barcode": barcode, - }, - ) - move_lines = self.service._find_user_move_lines( - self.src_location, self.product_a - ) - self.assert_response_scan_destination_location( - response, move_lines.picking_id, move_lines - ) - - def test_confirm_quantity_product_confirm_ok(self): - response = self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_a.id, - "quantity": 10, - "confirm": True, - }, - ) - move_lines = self.service._find_user_move_lines( - self.src_location, self.product_a - ) - self.assert_response_scan_destination_location( - response, move_lines.picking_id, move_lines - ) - - def test_confirm_quantity_lot_barcode_ok(self): - barcode = self.product_b_lot.name - response = self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_b.id, - "quantity": 10, - "lot_id": self.product_b_lot.id, - "barcode": barcode, - }, - ) - move_lines = self.service._find_user_move_lines( - self.src_location, self.product_b, self.product_b_lot - ) - self.assertEqual(move_lines.lot_id, self.product_b_lot) - self.assert_response_scan_destination_location( - response, move_lines.picking_id, move_lines - ) - - def test_confirm_quantity_lot_confirm_ok(self): - response = self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_b.id, - "quantity": 10, - "lot_id": self.product_b_lot.id, - "confirm": True, - }, - ) - move_lines = self.service._find_user_move_lines( - self.src_location, self.product_b, self.product_b_lot - ) - self.assertEqual(move_lines.lot_id, self.product_b_lot) - self.assert_response_scan_destination_location( - response, move_lines.picking_id, move_lines - ) - - def test_confirm_quantity_with_unreservation_disabled(self): - self.menu.sudo().allow_unreserve_other_moves = False - # initial qty is 10, but we reserve 2 qties (so 8 fully free) - picking = self._create_picking( - picking_type=self.env.ref("stock.picking_type_out"), - lines=[(self.product_a, 2)], - confirm=True, - ) - picking.action_assign() - # confirm 9 qties to process (more than 8) - response = self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_a.id, - "quantity": 9, - "confirm": True, - }, - ) - # we get an error message and the quantity to move is reset to 8 - self.assert_response_confirm_quantity( - response, - self.src_location, - self.product_a, - 8, - message=self.service.msg_store.qty_exceeds_initial_qty(), - ) - move_lines = self.service._find_user_move_lines( - self.src_location, self.product_a - ) - self.assertFalse(move_lines) - - def test_confirm_quantity_with_unreservation_enabled(self): - self.menu.sudo().allow_unreserve_other_moves = True - # initial qty is 10, but we reserve 2 qties (so 8 fully free) - picking = self._create_picking( - picking_type=self.env.ref("stock.picking_type_out"), - lines=[(self.product_a, 2)], - confirm=True, - ) - picking.action_assign() - # confirm 9 qties to process (more than 8) - response = self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_a.id, - "quantity": 9, - "confirm": True, - }, - ) - # the existing move lines has been unreserve to satisfy the move of 9 - # qties to process - self.assertRecordValues( - picking.move_lines, - [ - { - "state": "partially_available", - "product_uom_qty": 2, - "reserved_availability": 1, - }, - ], - ) - move_lines = self.service._find_user_move_lines( - self.src_location, self.product_a - ) - self.assertRecordValues( - move_lines, [{"state": "assigned", "product_uom_qty": 9, "qty_done": 9}] - ) - self.assert_response_scan_destination_location( - response, move_lines.picking_id, move_lines - ) - - def test_confirm_quantity_exceeds_initial_qty(self): - # initial qty is 10, but we try to process 11 - response = self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_a.id, - "quantity": 11, - "confirm": True, - }, - ) - self.assert_response_confirm_quantity( - response, - self.src_location, - self.product_a, - 10, - message=self.service.msg_store.qty_exceeds_initial_qty(), - ) - - def test_confirm_quantity_with_putaway_destination_required_error(self): - self.menu.sudo().ignore_no_putaway_available = True - response = self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_a.id, - "quantity": 10, - "confirm": True, - }, - ) - self.assert_response_confirm_quantity( - response, - self.src_location, - self.product_a, - 10, - message=self.service.msg_store.no_putaway_destination_available(), - ) - - def test_set_quantity_ok(self): - # initial qty is 10 - response = self.service.dispatch( - "set_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_a.id, - "quantity": 9, - }, - ) - self.assert_response_confirm_quantity( - response, self.src_location, self.product_a, 9, - ) - - def test_set_quantity_exceeds_initial_qty(self): - # initial qty is 10 - response = self.service.dispatch( - "set_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_a.id, - "quantity": 11, - }, - ) - self.assert_response_confirm_quantity( - response, - self.src_location, - self.product_a, - 10, - message=self.service.msg_store.qty_exceeds_initial_qty(), - ) diff --git a/shopfloor/tests/test_manual_product_transfer_misc.py b/shopfloor/tests/test_manual_product_transfer_misc.py deleted file mode 100644 index b1ac5032b44..00000000000 --- a/shopfloor/tests/test_manual_product_transfer_misc.py +++ /dev/null @@ -1,47 +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_manual_product_transfer_base import ManualProductTransferCommonCase - - -class ManualProductTransferMisc(ManualProductTransferCommonCase): - """Test helper methods used in the manual product transfer scenario.""" - - def test_get_product_qty(self): - # 1. simple case - qty = self.service._get_product_qty(self.src_location, self.product_a) - self.assertEqual(qty, 10) - # 2. with some qties reserved: returns the qty available without reservation - picking = self._create_picking(lines=[(self.product_a, 4)], confirm=True) - picking.action_assign() - qty = self.service._get_product_qty( - self.src_location, self.product_a, free=True - ) - self.assertEqual(qty, 6) - - def test_get_product_qty_processed(self): - # No qty processed at first - qty = self.service._get_product_qty_processed(self.src_location, self.product_a) - self.assertEqual(qty, 0) - # Process some qties (without validation) and check the qty processed - picking = self._create_picking(lines=[(self.product_a, 10)], confirm=True) - picking.action_assign() - picking.move_line_ids.qty_done = 8 - qty = self.service._get_product_qty_processed(self.src_location, self.product_a) - self.assertEqual(qty, 8) - - def test_get_initial_qty(self): - # 1. simple case - qty = self.service._get_initial_qty(self.src_location, self.product_a) - self.assertEqual(qty, 10) - # 2. with some qties reserved and "Allow to process reserved quantities" - # option disabled: returns the qty available without reservation - picking = self._create_picking(lines=[(self.product_a, 4)], confirm=True) - picking.action_assign() - qty = self.service._get_initial_qty(self.src_location, self.product_a) - self.assertEqual(qty, 6) - # 3. with some qties reserved and "Allow to process reserved quantities" - # option enabled: returns the qty available reservation included - self.menu.sudo().allow_unreserve_other_moves = True - qty = self.service._get_initial_qty(self.src_location, self.product_a) - self.assertEqual(qty, 10) diff --git a/shopfloor/tests/test_manual_product_transfer_scan_destination_location.py b/shopfloor/tests/test_manual_product_transfer_scan_destination_location.py deleted file mode 100644 index 6140f592a94..00000000000 --- a/shopfloor/tests/test_manual_product_transfer_scan_destination_location.py +++ /dev/null @@ -1,66 +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_manual_product_transfer_base import ManualProductTransferCommonCase - - -class ManualProductTransferScanDestinationLocation(ManualProductTransferCommonCase): - """Tests for confirm_quantity state - - Endpoints: - - * /scan_destination_location - """ - - def test_scan_destination_location_wrong_picking_id(self): - response = self.service.dispatch( - "scan_destination_location", - params={"move_line_ids": [-1], "barcode": self.shelf1.barcode}, - ) - self.assert_response_start( - response, message=self.service.msg_store.record_not_found(), - ) - - def _confirm_quantity(self): - self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_a.id, - "quantity": 10, - "confirm": True, - }, - ) - move_lines = self.service._find_user_move_lines( - self.src_location, self.product_a - ) - picking = move_lines.picking_id - return picking, move_lines - - def test_scan_destination_location_wrong_destination(self): - picking, move_lines = self._confirm_quantity() - response = self.service.dispatch( - "scan_destination_location", - params={ - "move_line_ids": move_lines.ids, - "barcode": self.not_allowed_location.barcode, - }, - ) - self.assertEqual(move_lines.state, "assigned") - self.assert_response_scan_destination_location( - response, - picking, - move_lines, - message=self.service.msg_store.location_not_allowed(), - ) - - def test_scan_destination_location_ok(self): - picking, move_lines = self._confirm_quantity() - response = self.service.dispatch( - "scan_destination_location", - params={"move_line_ids": move_lines.ids, "barcode": self.shelf1.barcode}, - ) - self.assertEqual(move_lines.state, "done") - self.assert_response_start( - response, message=self.service.msg_store.transfer_done_success(picking) - ) diff --git a/shopfloor/tests/test_manual_product_transfer_scan_product.py b/shopfloor/tests/test_manual_product_transfer_scan_product.py deleted file mode 100644 index 55760722d91..00000000000 --- a/shopfloor/tests/test_manual_product_transfer_scan_product.py +++ /dev/null @@ -1,92 +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_manual_product_transfer_base import ManualProductTransferCommonCase - - -class ManualProductTransferScanProduct(ManualProductTransferCommonCase): - """Tests for scan_product state - - Endpoints: - - * /scan_product - """ - - def test_scan_product_not_found_error(self): - response = self.service.dispatch( - "scan_product", - params={"location_id": self.src_location.id, "barcode": "UNKNOWN"}, - ) - self.assert_response_scan_product( - response, - self.src_location, - message=self.service.msg_store.barcode_not_found(), - ) - - def test_scan_product_tracked_by_lot_error(self): - response = self.service.dispatch( - "scan_product", - params={ - "location_id": self.src_location.id, - "barcode": self.product_b.barcode, - }, - ) - self.assert_response_scan_product( - response, - self.src_location, - message=self.service.msg_store.scan_lot_on_product_tracked_by_lot(), - ) - - def test_scan_product_product_ok(self): - response = self.service.dispatch( - "scan_product", - params={ - "location_id": self.src_location.id, - "barcode": self.product_a.barcode, - }, - ) - self.assert_response_confirm_quantity( - response, self.src_location, self.product_a, 10, - ) - - def test_scan_product_lot_ok(self): - response = self.service.dispatch( - "scan_product", - params={ - "location_id": self.src_location.id, - "barcode": self.product_b_lot.name, - }, - ) - self.assert_response_confirm_quantity( - response, self.src_location, self.product_b, 10, self.product_b_lot, - ) - - def test_scan_product_recover_session(self): - # create a move and its move lines by confirming qty - self.service.dispatch( - "confirm_quantity", - params={ - "location_id": self.src_location.id, - "product_id": self.product_a.id, - "quantity": 10, - "confirm": True, - }, - ) - move_lines = self.service._find_user_move_lines( - self.src_location, self.product_a - ) - # check we are redirected to the "scan_destination_location" state - # when scanning the same product - response = self.service.dispatch( - "scan_product", - params={ - "location_id": self.src_location.id, - "barcode": self.product_a.barcode, - }, - ) - self.assert_response_scan_destination_location( - response, - move_lines.picking_id, - move_lines, - message=self.service.msg_store.recovered_previous_session(), - ) diff --git a/shopfloor/tests/test_manual_product_transfer_start.py b/shopfloor/tests/test_manual_product_transfer_start.py deleted file mode 100644 index 5f80f300c2d..00000000000 --- a/shopfloor/tests/test_manual_product_transfer_start.py +++ /dev/null @@ -1,44 +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_manual_product_transfer_base import ManualProductTransferCommonCase - - -class ManualProductTransferStart(ManualProductTransferCommonCase): - """Tests for start state - - Endpoints: - - * /scan_source_location - """ - - def test_scan_source_location_not_found(self): - response = self.service.dispatch( - "scan_source_location", params={"barcode": "UNKNOWN"} - ) - self.assert_response_start( - response, message=self.service.msg_store.no_location_found() - ) - - def test_scan_source_location_not_allowed(self): - response = self.service.dispatch( - "scan_source_location", - params={"barcode": self.not_allowed_location.barcode}, - ) - self.assert_response_start( - response, message=self.service.msg_store.location_not_allowed() - ) - - def test_scan_source_location_empty(self): - response = self.service.dispatch( - "scan_source_location", params={"barcode": self.empty_location.barcode} - ) - self.assert_response_start( - response, message=self.service.msg_store.location_empty(self.empty_location) - ) - - def test_scan_source_location(self): - response = self.service.dispatch( - "scan_source_location", params={"barcode": self.src_location.barcode} - ) - self.assert_response_scan_product(response, self.src_location) From 69cc5c5e241fd77aa0e1468ceb2e1f70d261e275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 13 Dec 2021 17:50:50 +0100 Subject: [PATCH 636/710] shopfloor: new option 'Force reservation' --- shopfloor/models/shopfloor_menu.py | 15 +++++++++++++++ shopfloor/models/stock_location.py | 1 - shopfloor/views/shopfloor_menu.xml | 7 +++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index fbe821cae02..28ad436338c 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -63,6 +63,14 @@ class ShopfloorMenu(models.Model): compute="_compute_pick_pack_same_time_is_possible" ) + allow_force_reservation = fields.Boolean( + string="Force stock reservation", + default=False, + ) + allow_force_reservation_is_possible = fields.Boolean( + compute="_compute_allow_force_reservation_is_possible" + ) + @api.depends("scenario_id", "picking_type_ids") def _compute_move_create_is_possible(self): for menu in self: @@ -158,3 +166,10 @@ def _check_move_entire_packages(self): "Please, adjust your configuration." ).format(scenario_name, "\n- ".join(bad_picking_types)) ) + + @api.depends("scenario_id") + def _compute_allow_force_reservation_is_possible(self): + for menu in self: + menu.allow_force_reservation_is_possible = menu.scenario_id.has_option( + "allow_force_reservation" + ) diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py index a809a0d4092..583739eb144 100644 --- a/shopfloor/models/stock_location.py +++ b/shopfloor/models/stock_location.py @@ -72,7 +72,6 @@ def planned_qty_in_location_is_empty(self, move_lines=None): def should_bypass_reservation(self): self.ensure_one() - # TODO: This could be set as an option on shopfloor.menu if self.env.context.get("force_reservation"): return False return super().should_bypass_reservation() diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index e6d1c8c202f..1f534fbe4dc 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -40,6 +40,13 @@ + + + + From 2e4bcc07f3ec57a06b3c42a31b4a3d79469ef5c1 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Tue, 14 Dec 2021 10:35:14 +0000 Subject: [PATCH 637/710] [UPD] Update shopfloor.pot --- shopfloor/i18n/shopfloor.pot | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot index 4db0726facc..1aaae752649 100644 --- a/shopfloor/i18n/shopfloor.pot +++ b/shopfloor/i18n/shopfloor.pot @@ -59,6 +59,11 @@ msgstr "" msgid "All packages processed." msgstr "" +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation_is_possible +msgid "Allow Force Reservation Is Possible" +msgstr "" + #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create msgid "Allow Move Creation" @@ -210,6 +215,11 @@ msgid "" "becomes empty after a move." msgstr "" +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation +msgid "Force stock reservation" +msgstr "" + #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id msgid "From" @@ -477,6 +487,18 @@ msgstr "" msgid "No product found among current transfers." msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found in {}" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -1192,6 +1214,18 @@ msgstr "" msgid "Wrong bin" msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong product." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format From cbcde4456c130765aa3f131db2297ef7fb3d1f20 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 14 Dec 2021 10:59:15 +0000 Subject: [PATCH 638/710] shopfloor 14.0.1.1.0 --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index 059be997e9c..d487b641820 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "14.0.1.0.2", + "version": "14.0.1.1.0", "development_status": "Alpha", "category": "Inventory", "website": "https://github.com/OCA/wms", From 8b206a000f8c931007331d89d294bfc16c8d967d Mon Sep 17 00:00:00 2001 From: OCA Transbot Date: Tue, 14 Dec 2021 10:59:37 +0000 Subject: [PATCH 639/710] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: wms-14.0/wms-14.0-shopfloor Translate-URL: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor/ --- shopfloor/i18n/ca.po | 34 ++++++++++++++++++++++++++++++++++ shopfloor/i18n/es_AR.po | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/shopfloor/i18n/ca.po b/shopfloor/i18n/ca.po index 451da7b9ce5..708224855ec 100644 --- a/shopfloor/i18n/ca.po +++ b/shopfloor/i18n/ca.po @@ -62,6 +62,11 @@ msgstr "" msgid "All packages processed." msgstr "Processats tots els paquets." +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation_is_possible +msgid "Allow Force Reservation Is Possible" +msgstr "" + #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create msgid "Allow Move Creation" @@ -215,6 +220,11 @@ msgid "" "becomes empty after a move." msgstr "" +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation +msgid "Force stock reservation" +msgstr "" + #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id msgid "From" @@ -483,6 +493,18 @@ msgstr "" msgid "No product found among current transfers." msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found in {}" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -1195,6 +1217,18 @@ msgstr "" msgid "Wrong bin" msgstr "" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong product." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 5ffc07845ea..715455878e8 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -71,6 +71,11 @@ msgstr "Agrega campos de prioridad / aplazamiento del taller" msgid "All packages processed." msgstr "Todos los paquetes procesados." +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation_is_possible +msgid "Allow Force Reservation Is Possible" +msgstr "" + #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create msgid "Allow Move Creation" @@ -227,6 +232,11 @@ msgstr "" "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,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation +msgid "Force stock reservation" +msgstr "" + #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id msgid "From" @@ -502,6 +512,18 @@ 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 product found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found in {}" +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format @@ -1252,6 +1274,18 @@ msgstr "Es posible Anular la Reserva de Otros Movimientos" msgid "Wrong bin" msgstr "Compartimento incorrecto" +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong product." +msgstr "" + #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format From 38cf46bcc11aee327b1e55ff836cb5049b3f5839 Mon Sep 17 00:00:00 2001 From: Ignacio Buioli Date: Mon, 27 Dec 2021 02:48:03 +0000 Subject: [PATCH 640/710] Translated using Weblate (Spanish (Argentina)) Currently translated at 100.0% (208 of 208 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 | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po index 715455878e8..ff3cf48d6de 100644 --- a/shopfloor/i18n/es_AR.po +++ b/shopfloor/i18n/es_AR.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 13.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2021-10-19 23:35+0000\n" +"PO-Revision-Date: 2021-12-27 05:39+0000\n" "Last-Translator: Ignacio Buioli \n" "Language-Team: none\n" "Language: es_AR\n" @@ -74,7 +74,7 @@ msgstr "Todos los paquetes procesados." #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation_is_possible msgid "Allow Force Reservation Is Possible" -msgstr "" +msgstr "Permitir Forzar Reserva es Posible" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create @@ -235,7 +235,7 @@ msgstr "" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation msgid "Force stock reservation" -msgstr "" +msgstr "Forzar reserva de inventario" #. module: shopfloor #: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id @@ -516,13 +516,13 @@ msgstr "" #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No product found for {}" -msgstr "" +msgstr "No se encontró producto para {}" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "No product found in {}" -msgstr "" +msgstr "No se encontró producto en {}" #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 @@ -1278,13 +1278,13 @@ msgstr "Compartimento incorrecto" #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Wrong lot." -msgstr "" +msgstr "Lote equivocado." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 #, python-format msgid "Wrong product." -msgstr "" +msgstr "Producto equivocado." #. module: shopfloor #: code:addons/shopfloor/actions/message.py:0 From 3d328b0df682953c89a97040702d89f58e84fcd9 Mon Sep 17 00:00:00 2001 From: Marcel Savegnago Date: Tue, 28 Dec 2021 15:01:14 +0000 Subject: [PATCH 641/710] Added translation using Weblate (Portuguese (Brazil)) --- shopfloor/i18n/pt_BR.po | 1299 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1299 insertions(+) create mode 100644 shopfloor/i18n/pt_BR.po diff --git a/shopfloor/i18n/pt_BR.po b/shopfloor/i18n/pt_BR.po new file mode 100644 index 00000000000..320649de4c3 --- /dev/null +++ b/shopfloor/i18n/pt_BR.po @@ -0,0 +1,1299 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: pt_BR\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 +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "" +"\n" +"If you tick this box, while picking goods from a location\n" +"(eg: zone picking) set destination will work as follow:\n" +"\n" +"* if a location is scanned, a new delivery package is created;\n" +"* if a package is scanned, the package is validated against the carrier\n" +"* in both cases, if the picking has no carrier the operation fails.\",\n" +msgstr "" + +#. 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,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_force_reservation_is_possible +msgid "Allow Force Reservation Is Possible" +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 +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Are you sure?" +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 +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__bulk_line_count +msgid "Bulk Line Count" +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:shopfloor.scenario,name:shopfloor.scenario_checkout +#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo +msgid "Checkout" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_cluster_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_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 +#: 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 +#: code:addons/shopfloor/models/stock_move.py:0 +#, python-format +msgid "" +"Created from backorder %s." +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:shopfloor.menu,name:shopfloor.shopfloor_menu_delivery +#: model:shopfloor.scenario,name:shopfloor.scenario_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_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_stock_inventory__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__display_name +msgid "Display Name" +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,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation +msgid "Force stock reservation" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id +msgid "From" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Goods packed into {0.name}" +msgstr "" + +#. module: shopfloor +#: 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_stock_inventory__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__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_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_stock_inventory____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_location____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package____last_update +msgid "Last Modified 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 +#: model:shopfloor.scenario,name:shopfloor.scenario_location_content_transfer +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 +#: 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.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 +#: 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 delivery package type available." +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 {}." +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 product found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found in {}" +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/actions/message.py:0 +#, python-format +msgid "No valid package to select." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No value" +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 +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__package_level_count +msgid "Package Level Count" +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/actions/message.py:0 +#, python-format +msgid "Packaging '{}' is not allowed for carrier {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Packaging changed on package {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Pick + Pack mode ON: the picking {0.name} has no carrier set. The system " +"couldn't pack goods automatically." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time_is_possible +msgid "Pick Pack Same Time Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "Pick and pack at the same time" +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) processed as raw product(s)" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant +msgid "Quants" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Real pack weight or the estimated one." +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_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_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 "Same package {} is already assigned." +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 +#: 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 +#: 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_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.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.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Shopfloor weight (kg)" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_single_pack_transfer +msgid "Single Pack Transfer" +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,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,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__bulk_line_count +msgid "" +"Technical field. Indicates number of move lines without package included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__package_level_count +msgid "Technical field. Indicates number of package_level 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 +#: code:addons/shopfloor/models/stock_picking.py:0 +#, python-format +msgid "" +"The backorder %s has been created." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The content of {} cannot be transferred with this scenario." +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 cannot be transferred with this scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't contain any product to take." +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/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: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 +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id +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 +#: 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 "Wrong lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong product." +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/message.py:0 +#, python-format +msgid "" +"You scanned a different package with the same product, do you want to change" +" pack? Scan it again to confirm" +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:shopfloor.scenario,name:shopfloor.scenario_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 "{} is not a valid destination package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} {} put in {}" +msgstr "" From e025feb9af996f5315bd2c056eedd2e7fd9039bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 7 Jan 2022 11:28:48 +0100 Subject: [PATCH 642/710] [IMP] shopfloor: performance improvement On `stock.package_level`, add an index on `package_id` and automatically join `stock_picking` records through `picking_id` to improve performance. Single pack transfer scenario is performing search operations on these fields. The same changes have been applied on `stock.move.line` in this commit: 30149ce6c --- shopfloor/models/stock_package_level.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py index 764b00af70d..29b7f180afb 100644 --- a/shopfloor/models/stock_package_level.py +++ b/shopfloor/models/stock_package_level.py @@ -1,12 +1,17 @@ # 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 +from odoo import fields, models class StockPackageLevel(models.Model): _name = "stock.package_level" _inherit = ["stock.package_level", "shopfloor.priority.postpone.mixin"] + # we search package levels based on their package in some workflows + package_id = fields.Many2one(index=True) + # allow domain on picking_id.xxx without too much perf penalty + picking_id = fields.Many2one(auto_join=True) + def explode_package(self): """Unlink but keep the moves. From 9b855b00cbb84e222e34b89ad4173c5af5aea913 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 18 Jan 2022 15:46:10 +0100 Subject: [PATCH 643/710] shopfloor: dev status -> Beta --- shopfloor/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index d487b641820..e3c98647b0b 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -7,7 +7,7 @@ "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", "version": "14.0.1.1.0", - "development_status": "Alpha", + "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", "author": "Camptocamp, BCIM, Akretion, Odoo Community Association (OCA)", From 7727316561d633e199035299c257eba5f3ea2958 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 19 Jan 2022 10:12:10 +0000 Subject: [PATCH 644/710] [UPD] README.rst --- shopfloor/README.rst | 9 ++------- shopfloor/static/description/index.html | 8 +------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/shopfloor/README.rst b/shopfloor/README.rst index 844e66ab58f..2c594e3be69 100644 --- a/shopfloor/README.rst +++ b/shopfloor/README.rst @@ -7,9 +7,9 @@ Shopfloor !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status - :alt: Alpha + :alt: Beta .. |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 @@ -40,11 +40,6 @@ 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:: diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html index 082f1631964..ad30dad4ef6 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. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

    Alpha License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runbot

    +

    Beta License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runbot

    Shopfloor is a barcode scanner application for internal warehouse operations.

    The application supports scenarios, to relate to Operation Types: